From 4caccebea68f963160b704182600830fffac2d3c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 13:12:16 +0200 Subject: [PATCH 01/63] Add generated TUS protocol contract canary --- .github/workflows/CI.yml | 3 +- .../client/GeneratedTusProtocolContract.java | 461 ++++++++++++++++++ .../GeneratedTusProtocolContractTest.java | 57 +++ 3 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java create mode 100644 tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContractTest.java diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 866e680..74472b2 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,16 +1,17 @@ name: CI on: + workflow_dispatch: push: branches: - main pull_request: types: - opened + - ready_for_review - synchronize - unlabeled - runs-on: ubuntu-latest jobs: android: strategy: diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java new file mode 100644 index 0000000..4a2a5fd --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -0,0 +1,461 @@ +/* + * Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. + * If it looks wrong, please report the issue instead of editing this file by hand; + * the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + */ + +package io.tus.android.client; + +/** + * Generated TUS protocol contract fixture used by tests. + */ +final class GeneratedTusProtocolContract { + static final GeneratedTusWireVersion[] WIRE_VERSIONS = new GeneratedTusWireVersion[] { + new GeneratedTusWireVersion( + true, + "1.0.0" + ), + }; + + static final GeneratedTusProtocolOperation[] OPERATIONS = new GeneratedTusProtocolOperation[] { + new GeneratedTusProtocolOperation( + "discoverTusCapabilities", + "capability-discovery", + "OPTIONS", + "/resumable/files/", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[0] + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 200, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Extension", + "tus-extension", + true + ), + new GeneratedTusHeaderField( + "Tus-Max-Size", + "tus-max-size", + true + ), + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Tus-Version", + "tus-version", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "createTusUpload", + "creation", + "POST", + "/resumable/files/", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Length", + "upload-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Metadata", + "upload-metadata", + true + ), + } + ), + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Defer-Length", + "upload-defer-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Metadata", + "upload-metadata", + true + ), + } + ), + } + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 201, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Location", + "location", + true + ), + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "getTusUploadOffset", + "offset-discovery", + "HEAD", + "/resumable/files/{upload_id}", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 200, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Length", + "upload-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Offset", + "upload-offset", + true + ), + } + ), + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Defer-Length", + "upload-defer-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Offset", + "upload-offset", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "patchTusUpload", + "upload-chunk", + "PATCH", + "/resumable/files/{upload_id}", + new GeneratedTusRequestContract( + "binary", + "application/offset+octet-stream", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Content-Type", + "content-type", + true + ), + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Offset", + "upload-offset", + true + ), + } + ), + } + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 204, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Offset", + "upload-offset", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "terminateTusUpload", + "termination", + "DELETE", + "/resumable/files/{upload_id}", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 204, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), + } + ), + new GeneratedTusProtocolOperation( + "downloadTusUpload", + "download", + "GET", + "/resumable/files/{upload_id}", + new GeneratedTusRequestContract( + "empty", + null, + new GeneratedTusHeaderVariant[0] + ), + new GeneratedTusResponseContract[] { + new GeneratedTusResponseContract( + 200, + "binary", + new GeneratedTusHeaderVariant[0] + ), + } + ), + }; + + static final GeneratedTusClientFeature[] CLIENT_FEATURES = new GeneratedTusClientFeature[] { + new GeneratedTusClientFeature( + "singleUploadLifecycle", + new String[] { + "createTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "open-input-source", + "fingerprint-input", + "store-resume-url", + "retry-with-backoff", + "emit-progress", + "abort-current-request", + } + ), + new GeneratedTusClientFeature( + "terminateUpload", + new String[] { + "terminateTusUpload", + }, + new String[] { + "retry-with-backoff", + } + ), + }; + + private GeneratedTusProtocolContract() { + } + + /** + * Generated wire-version fixture. + */ + static final class GeneratedTusWireVersion { + final boolean defaultVersion; + final String value; + + GeneratedTusWireVersion(boolean defaultVersion, String value) { + this.defaultVersion = defaultVersion; + this.value = value; + } + } + + /** + * Generated HTTP header field fixture. + */ + static final class GeneratedTusHeaderField { + final String displayName; + final String name; + final boolean required; + + GeneratedTusHeaderField(String displayName, String name, boolean required) { + this.displayName = displayName; + this.name = name; + this.required = required; + } + } + + /** + * Generated alternative HTTP header set fixture. + */ + static final class GeneratedTusHeaderVariant { + final GeneratedTusHeaderField[] fields; + + GeneratedTusHeaderVariant(GeneratedTusHeaderField[] fields) { + this.fields = fields; + } + } + + /** + * Generated request contract fixture. + */ + static final class GeneratedTusRequestContract { + final String bodyKind; + final String contentType; + final GeneratedTusHeaderVariant[] headerVariants; + + GeneratedTusRequestContract( + String bodyKind, + String contentType, + GeneratedTusHeaderVariant[] headerVariants) { + this.bodyKind = bodyKind; + this.contentType = contentType; + this.headerVariants = headerVariants; + } + } + + /** + * Generated response contract fixture. + */ + static final class GeneratedTusResponseContract { + final int statusCode; + final String bodyKind; + final GeneratedTusHeaderVariant[] headerVariants; + + GeneratedTusResponseContract( + int statusCode, + String bodyKind, + GeneratedTusHeaderVariant[] headerVariants) { + this.statusCode = statusCode; + this.bodyKind = bodyKind; + this.headerVariants = headerVariants; + } + } + + /** + * Generated protocol operation fixture. + */ + static final class GeneratedTusProtocolOperation { + final String operationId; + final String role; + final String method; + final String path; + final GeneratedTusRequestContract request; + final GeneratedTusResponseContract[] responses; + + GeneratedTusProtocolOperation( + String operationId, + String role, + String method, + String path, + GeneratedTusRequestContract request, + GeneratedTusResponseContract[] responses) { + this.operationId = operationId; + this.role = role; + this.method = method; + this.path = path; + this.request = request; + this.responses = responses; + } + } + + /** + * Generated client feature fixture. + */ + static final class GeneratedTusClientFeature { + final String featureId; + final String[] operationIds; + final String[] primitives; + + GeneratedTusClientFeature(String featureId, String[] operationIds, String[] primitives) { + this.featureId = featureId; + this.operationIds = operationIds; + this.primitives = primitives; + } + } +} diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContractTest.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContractTest.java new file mode 100644 index 0000000..b87b2ac --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContractTest.java @@ -0,0 +1,57 @@ +package io.tus.android.client; + +import android.app.Activity; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; + +import java.net.URL; + +import static org.junit.Assert.assertEquals; + +@RunWith(RobolectricTestRunner.class) +public class GeneratedTusProtocolContractTest { + + @Test + public void shouldCoverResumeStoragePrimitive() throws Exception { + GeneratedTusProtocolContract.GeneratedTusClientFeature feature = + findFeature("singleUploadLifecycle"); + + assertContains(feature.operationIds, "createTusUpload"); + assertContains(feature.operationIds, "getTusUploadOffset"); + assertContains(feature.operationIds, "patchTusUpload"); + assertContains(feature.primitives, "store-resume-url"); + + Activity activity = Robolectric.setupActivity(Activity.class); + TusPreferencesURLStore store = new TusPreferencesURLStore( + activity.getSharedPreferences("generated-tus-contract-test", 0)); + URL url = new URL("https://tusd.tusdemo.net/files/generated"); + store.set("fingerprint", url); + + assertEquals(url, store.get("fingerprint")); + } + + private static GeneratedTusProtocolContract.GeneratedTusClientFeature findFeature( + String featureId) { + for (GeneratedTusProtocolContract.GeneratedTusClientFeature feature + : GeneratedTusProtocolContract.CLIENT_FEATURES) { + if (feature.featureId.equals(featureId)) { + return feature; + } + } + + throw new AssertionError("Missing generated TUS client feature: " + featureId); + } + + private static void assertContains(String[] values, String expected) { + for (String value : values) { + if (value.equals(expected)) { + return; + } + } + + throw new AssertionError("Missing generated value: " + expected); + } +} From 761a742eb45b42e9f0710aa159c795c8fd47084c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 21:18:33 +0200 Subject: [PATCH 02/63] Regenerate TUS protocol contract fixture --- .../client/GeneratedTusProtocolContract.java | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 4a2a5fd..661ff73 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -108,6 +108,49 @@ final class GeneratedTusProtocolContract { ), } ), + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Concat", + "upload-concat", + true + ), + new GeneratedTusHeaderField( + "Upload-Length", + "upload-length", + true + ), + new GeneratedTusHeaderField( + "Upload-Metadata", + "upload-metadata", + false + ), + } + ), + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + new GeneratedTusHeaderField( + "Upload-Concat", + "upload-concat", + true + ), + new GeneratedTusHeaderField( + "Upload-Metadata", + "upload-metadata", + false + ), + } + ), } ), new GeneratedTusResponseContract[] { @@ -328,12 +371,79 @@ final class GeneratedTusProtocolContract { "abort-current-request", } ), + new GeneratedTusClientFeature( + "resumeUpload", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "fingerprint-input", + "resume-from-previous-upload", + "store-resume-url", + } + ), + new GeneratedTusClientFeature( + "deferredLengthUpload", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "defer-upload-length", + "emit-progress", + } + ), + new GeneratedTusClientFeature( + "creationWithUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + } + ), + new GeneratedTusClientFeature( + "overridePatchMethod", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "override-patch-method", + } + ), + new GeneratedTusClientFeature( + "parallelUploadConcat", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "concatenate-partial-uploads", + "emit-progress", + } + ), + new GeneratedTusClientFeature( + "retryOffsetRecovery", + new String[] { + "createTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "retry-with-backoff", + "recover-offset-after-error", + } + ), new GeneratedTusClientFeature( "terminateUpload", new String[] { "terminateTusUpload", }, new String[] { + "terminate-upload", "retry-with-backoff", } ), From 7151e44d6b4c994d167b4c85e885378920a97936 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 26 May 2026 22:12:27 +0200 Subject: [PATCH 03/63] Regenerate TUS feature contract fixture --- .../client/GeneratedTusProtocolContract.java | 566 +++++++++++++++++- 1 file changed, 565 insertions(+), 1 deletion(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 661ff73..3331393 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -356,7 +356,37 @@ final class GeneratedTusProtocolContract { static final GeneratedTusClientFeature[] CLIENT_FEATURES = new GeneratedTusClientFeature[] { new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "singleUploadLifecycle", + }, + "covered-by-generated-scenario" + ), + "Create an upload, store its URL, upload bytes, and finish successfully.", "singleUploadLifecycle", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "open-input-source", + "", + "Open the caller input as a sliceable source." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create the remote upload resource." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes until the accepted offset reaches the known length." + ), + }, new String[] { "createTusUpload", "getTusUploadOffset", @@ -372,7 +402,37 @@ final class GeneratedTusProtocolContract { } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "resumeFromPreviousUpload", + }, + "covered-by-generated-scenario" + ), + "Resume a stored upload URL by discovering the remote offset before patching.", "resumeUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "resume-from-previous-upload", + "", + "Load a stored upload URL selected by fingerprint." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "getTusUploadOffset", + "", + "", + "Read the server offset for the stored upload URL." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Continue uploading from the discovered offset." + ), + }, new String[] { "getTusUploadOffset", "patchTusUpload", @@ -384,7 +444,37 @@ final class GeneratedTusProtocolContract { } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "deferredLengthUpload", + }, + "covered-by-generated-scenario" + ), + "Create an upload without a known length and declare the length on final PATCH.", "deferredLengthUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create the upload with deferred length." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "defer-upload-length", + "", + "Track the source until the final chunk reveals the total size." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Declare Upload-Length on the final chunk request." + ), + }, new String[] { "createTusUpload", "patchTusUpload", @@ -395,7 +485,30 @@ final class GeneratedTusProtocolContract { } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "creationWithUpload", + }, + "covered-by-generated-scenario" + ), + "Send the first bytes on the creation request when the server/client support it.", "creationWithUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create the upload while streaming the initial body." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "upload-during-creation", + "", + "Interpret the creation response as an accepted offset." + ), + }, new String[] { "createTusUpload", }, @@ -405,7 +518,37 @@ final class GeneratedTusProtocolContract { } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "overridePatchMethod", + }, + "covered-by-generated-scenario" + ), + "Tunnel PATCH through POST with the method-override header.", "overridePatchMethod", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "operation", + "getTusUploadOffset", + "", + "", + "Resume from the upload URL before sending bytes." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "override-patch-method", + "", + "Replace PATCH with POST while preserving the protocol operation intent." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes through the overridden request." + ), + }, new String[] { "getTusUploadOffset", "patchTusUpload", @@ -415,7 +558,37 @@ final class GeneratedTusProtocolContract { } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "parallelUploadConcat", + }, + "covered-by-generated-scenario" + ), + "Split one input into partial uploads and concatenate their upload URLs.", "parallelUploadConcat", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "split-parallel-upload-boundaries", + "", + "Split the input into stable byte ranges." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create partial uploads for each range." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "concatenate-partial-uploads", + "", + "Create the final upload from completed partial upload URLs." + ), + }, new String[] { "createTusUpload", "patchTusUpload", @@ -423,10 +596,41 @@ final class GeneratedTusProtocolContract { new String[] { "concatenate-partial-uploads", "emit-progress", + "split-parallel-upload-boundaries", } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "retryPatchAfterOffsetRecovery", + }, + "covered-by-generated-scenario" + ), + "Recover from a failed chunk by reading the server offset before retrying.", "retryOffsetRecovery", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Attempt the chunk upload." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "recover-offset-after-error", + "", + "Discover the accepted offset after a retryable failure." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "getTusUploadOffset", + "", + "", + "Use HEAD to recover the offset before retrying PATCH." + ), + }, new String[] { "createTusUpload", "getTusUploadOffset", @@ -438,7 +642,30 @@ final class GeneratedTusProtocolContract { } ), new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "terminateWithRetry", + }, + "covered-by-generated-scenario" + ), + "Terminate an upload resource and retry retryable termination failures.", "terminateUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "terminate-upload", + "", + "Choose server-side termination for an upload URL." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "terminateTusUpload", + "", + "", + "Delete the upload resource." + ), + }, new String[] { "terminateTusUpload", }, @@ -447,6 +674,294 @@ final class GeneratedTusProtocolContract { "retry-with-backoff", } ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Abort the active request, pending retry timer, and any partial uploads.", + "abortUpload", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "abort-current-request", + "", + "Cancel in-flight transport work without emitting user callbacks after abort." + ), + }, + new String[0], + new String[] { + "abort-current-request", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Expose progress and accepted-chunk callbacks from runtime upload activity.", + "uploadCallbacks", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "emit-progress", + "", + "Report bytes sent against known or deferred length." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "emit-chunk-complete", + "", + "Report chunk size, accepted offset, and total size after server acceptance." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "emit-upload-url", + "", + "Notify once a usable upload URL is known." + ), + }, + new String[0], + new String[] { + "emit-progress", + "emit-chunk-complete", + "emit-upload-url", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Run before-request, after-response, and custom retry hooks around transport.", + "requestLifecycleHooks", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "run-request-hooks", + "", + "Call user hooks around each HTTP request/response pair." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "customize-retry", + "", + "Let user retry policy override default retry decisions." + ), + }, + new String[0], + new String[] { + "customize-retry", + "run-request-hooks", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Persist, find, resume, and optionally remove upload URLs by fingerprint.", + "resumeUrlStorage", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "fingerprint-input", + "", + "Derive a stable key for the input when possible." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "store-resume-url", + "", + "Persist upload URLs and partial-upload URLs for future resumption." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "remove-stored-url-on-success", + "", + "Remove stored upload URLs when configured after success or invalidation." + ), + }, + new String[0], + new String[] { + "fingerprint-input", + "store-resume-url", + "remove-stored-url-on-success", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Support the reference client input/source families across runtimes.", + "inputSources", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "read-browser-file", + "", + "Read browser Blob/File and ArrayBuffer-family inputs." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "read-node-stream", + "", + "Read Node streams when size and chunk constraints are satisfied." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "read-web-stream", + "", + "Read Web Streams with deferred or configured size." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "read-node-file", + "", + "Read filesystem paths and fs streams, including parallel ranges." + ), + }, + new String[0], + new String[] { + "read-browser-file", + "read-node-file", + "read-node-stream", + "read-web-stream", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Support browser and file-backed URL storage implementations.", + "urlStorageBackends", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "store-browser-url", + "", + "Persist upload records in browser localStorage." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "store-file-url", + "", + "Persist upload records in the Node file store." + ), + }, + new String[0], + new String[] { + "store-browser-url", + "store-file-url", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Select between tus v1 and supported IETF draft client protocol modes.", + "protocolVersionSelection", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "select-client-protocol", + "", + "Choose request headers and response expectations for the selected protocol." + ), + }, + new String[] { + "createTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "select-client-protocol", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Normalize relative Location headers against the request endpoint.", + "relativeLocationResolution", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "resolve-relative-location", + "", + "Resolve server Location headers with the creation endpoint as origin." + ), + }, + new String[] { + "createTusUpload", + }, + new String[] { + "resolve-relative-location", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Validate option combinations before starting runtime work.", + "startOptionValidation", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "validate-start-options", + "", + "Reject missing inputs and incompatible parallel/deferred/resume options." + ), + }, + new String[0], + new String[] { + "validate-start-options", + } + ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[0], + "needs-generated-scenario" + ), + "Attach request, response, status, body, and request ID context to errors.", + "detailedErrors", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "report-detailed-errors", + "", + "Return user-facing errors with enough transport context for debugging." + ), + }, + new String[0], + new String[] { + "report-detailed-errors", + } + ), }; private GeneratedTusProtocolContract() { @@ -558,14 +1073,63 @@ static final class GeneratedTusProtocolOperation { * Generated client feature fixture. */ static final class GeneratedTusClientFeature { + final GeneratedTusClientFeatureConformance conformance; + final String description; final String featureId; + final GeneratedTusClientFeatureFlowStep[] flow; final String[] operationIds; final String[] primitives; - GeneratedTusClientFeature(String featureId, String[] operationIds, String[] primitives) { + GeneratedTusClientFeature( + GeneratedTusClientFeatureConformance conformance, + String description, + String featureId, + GeneratedTusClientFeatureFlowStep[] flow, + String[] operationIds, + String[] primitives) { + this.conformance = conformance; + this.description = description; this.featureId = featureId; + this.flow = flow; this.operationIds = operationIds; this.primitives = primitives; } } + + /** + * Generated client feature conformance coverage. + */ + static final class GeneratedTusClientFeatureConformance { + final String[] scenarioIds; + final String status; + + GeneratedTusClientFeatureConformance(String[] scenarioIds, String status) { + this.scenarioIds = scenarioIds; + this.status = status; + } + } + + /** + * Generated client feature flow step. + */ + static final class GeneratedTusClientFeatureFlowStep { + final String kind; + final String operationId; + final String primitive; + final String condition; + final String summary; + + GeneratedTusClientFeatureFlowStep( + String kind, + String operationId, + String primitive, + String condition, + String summary) { + this.kind = kind; + this.operationId = operationId; + this.primitive = primitive; + this.condition = condition; + this.summary = summary; + } + } } From c692d53cd5713e7a25d6e42b9a79d79432f34cb6 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 27 May 2026 11:35:33 +0200 Subject: [PATCH 04/63] Regenerate upload body protocol fixture --- .../client/GeneratedTusProtocolContract.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 3331393..1c35c17 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -517,6 +517,39 @@ final class GeneratedTusProtocolContract { "emit-progress", } ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "uploadBodyHeaders", + }, + "covered-by-generated-scenario" + ), + "Send protocol-specific upload body headers whenever the client transmits file bytes.", + "uploadBodyHeaders", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "send-upload-body-headers", + "", + "Attach the protocol-specific upload body content type when a request has bytes." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes with the protocol-specific body headers." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "send-upload-body-headers", + } + ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( new String[] { From 92bc15a64784eb67285550cf316fb8cc358f62f7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 22:39:50 +0200 Subject: [PATCH 05/63] Assert generated TUS upload events --- .../tus/android/client/GeneratedTusProtocolContract.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 1c35c17..c89a4ea 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -730,8 +730,12 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "singleUploadLifecycle", + "creationWithUpload", + "resumeFromPreviousUpload", + }, + "covered-by-generated-scenario" ), "Expose progress and accepted-chunk callbacks from runtime upload activity.", "uploadCallbacks", From e833d4a6f16f142724716eb64f66c40325cb7374 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 28 May 2026 23:19:53 +0200 Subject: [PATCH 06/63] Cover TUS request lifecycle conformance --- .../tus/android/client/GeneratedTusProtocolContract.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index c89a4ea..46bd6e0 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -771,8 +771,11 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "requestLifecycleHooks", + "retryPatchAfterOffsetRecovery", + }, + "covered-by-generated-scenario" ), "Run before-request, after-response, and custom retry hooks around transport.", "requestLifecycleHooks", From 6152e848144faef59ef6b43337b091664bb30bf3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:06:10 +0200 Subject: [PATCH 07/63] Cover TUS abort conformance --- .../io/tus/android/client/GeneratedTusProtocolContract.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 46bd6e0..9308e76 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -709,8 +709,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "abortUpload", + }, + "covered-by-generated-scenario" ), "Abort the active request, pending retry timer, and any partial uploads.", "abortUpload", From b952b55572457bd6d17f66afb10d8f749aca8995 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:14:09 +0200 Subject: [PATCH 08/63] Cover TUS URL storage conformance --- .../tus/android/client/GeneratedTusProtocolContract.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 9308e76..42052a1 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -805,8 +805,11 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "singleUploadLifecycle", + "resumeFromPreviousUpload", + }, + "covered-by-generated-scenario" ), "Persist, find, resume, and optionally remove upload URLs by fingerprint.", "resumeUrlStorage", From ca665cdba2935d6742803e6ba7d973afc9ae188b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:19:28 +0200 Subject: [PATCH 09/63] Cover TUS relative Location conformance --- .../io/tus/android/client/GeneratedTusProtocolContract.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 42052a1..3d311bd 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -944,8 +944,10 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "relativeLocationResolution", + }, + "covered-by-generated-scenario" ), "Normalize relative Location headers against the request endpoint.", "relativeLocationResolution", From 864c6db904628f7f9830106d2c90712a6cd7e78c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 07:48:41 +0200 Subject: [PATCH 10/63] Refresh TUS input source contract --- .../android/client/GeneratedTusProtocolContract.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 3d311bd..a6bd3d0 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -845,8 +845,14 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "arrayBufferInput", + "arrayBufferViewInput", + "webReadableStreamInput", + "nodeReadableStreamInput", + "nodePathInput", + }, + "covered-by-generated-scenario" ), "Support the reference client input/source families across runtimes.", "inputSources", From cc2974c899a571a6a64ec34f6017bf0cfc395bf6 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 18:10:38 +0200 Subject: [PATCH 11/63] Refresh TUS retry state contract --- .../client/GeneratedTusProtocolContract.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index a6bd3d0..4951e29 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -674,6 +674,41 @@ final class GeneratedTusProtocolContract { "recover-offset-after-error", } ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "retryPatchAfterOffsetRecovery", + }, + "covered-by-generated-scenario" + ), + "Schedule retry timers and reset retry attempts after accepted progress.", + "retryStateTransitions", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "schedule-retry-timer", + "", + "Consume the current retry delay and restart the upload after that timer fires." + ), + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "reset-retry-attempt-after-progress", + "", + "Reset retry attempts once a later retry observes server-side offset progress." + ), + }, + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "retry-with-backoff", + "schedule-retry-timer", + "reset-retry-attempt-after-progress", + } + ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( new String[] { From 0c3fe51c27380bcd056a7a4469f52e6fbc8aee47 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 20:09:07 +0200 Subject: [PATCH 12/63] Refresh TUS URL storage contract --- .../tus/android/client/GeneratedTusProtocolContract.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 4951e29..a31b45f 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -931,8 +931,11 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "webStorageUrlStorageBackend", + "fileUrlStorageBackend", + }, + "covered-by-generated-scenario" ), "Support browser and file-backed URL storage implementations.", "urlStorageBackends", From 5126e956c789f38ac22047e80c6db35379db924e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 21:06:25 +0200 Subject: [PATCH 13/63] Refresh TUS protocol selection contract --- .../tus/android/client/GeneratedTusProtocolContract.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index a31b45f..50f074c 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -963,8 +963,11 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "ietfDraft05CreationWithUpload", + "ietfDraft03ResumeWithoutKnownLength", + }, + "covered-by-generated-scenario" ), "Select between tus v1 and supported IETF draft client protocol modes.", "protocolVersionSelection", From a95b54050f40ab40730677dfd3f1d0b8afcead3b Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 22:26:26 +0200 Subject: [PATCH 14/63] Refresh TUS start validation contract --- .../client/GeneratedTusProtocolContract.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 50f074c..999acdd 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1016,8 +1016,18 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "startValidationMissingInput", + "startValidationMissingEndpointOrUploadUrl", + "startValidationUnsupportedProtocol", + "startValidationRetryDelaysNotArray", + "startValidationParallelUploadsWithUploadUrl", + "startValidationParallelUploadsWithUploadSize", + "startValidationParallelUploadsWithDeferredLength", + "startValidationParallelBoundariesWithoutParallelUploads", + "startValidationParallelBoundariesLengthMismatch", + }, + "covered-by-generated-scenario" ), "Validate option combinations before starting runtime work.", "startOptionValidation", From 70bb7f009b90ed2e4df18492fa2020bf7a0af61f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 29 May 2026 23:10:16 +0200 Subject: [PATCH 15/63] Update detailed error conformance --- .../tus/android/client/GeneratedTusProtocolContract.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 999acdd..b7807fc 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1047,8 +1047,11 @@ final class GeneratedTusProtocolContract { ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( - new String[0], - "needs-generated-scenario" + new String[] { + "detailedCreateResponseError", + "detailedCreateRequestError", + }, + "covered-by-generated-scenario" ), "Attach request, response, status, body, and request ID context to errors.", "detailedErrors", From af0c4dfe8053a1e2da91fedf76dcab48f5dde32a Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 31 May 2026 23:23:32 +0200 Subject: [PATCH 16/63] Regenerate TUS protocol fixture --- .../client/GeneratedTusProtocolContract.java | 102 +++++++++++++++++- 1 file changed, 100 insertions(+), 2 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index b7807fc..8ba8b05 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -488,6 +488,7 @@ final class GeneratedTusProtocolContract { new GeneratedTusClientFeatureConformance( new String[] { "creationWithUpload", + "creationWithUploadPartialChunk", }, "covered-by-generated-scenario" ), @@ -511,6 +512,7 @@ final class GeneratedTusProtocolContract { }, new String[] { "createTusUpload", + "patchTusUpload", }, new String[] { "upload-during-creation", @@ -550,6 +552,46 @@ final class GeneratedTusProtocolContract { "send-upload-body-headers", } ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "customRequestHeaders", + }, + "covered-by-generated-scenario" + ), + "Apply user-provided request headers to every upload request.", + "customRequestHeaders", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "apply-custom-request-headers", + "", + "Merge user-provided headers after protocol headers are prepared." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create uploads with the configured custom headers." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes with the configured custom headers." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "apply-custom-request-headers", + } + ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( new String[] { @@ -594,10 +636,11 @@ final class GeneratedTusProtocolContract { new GeneratedTusClientFeatureConformance( new String[] { "parallelUploadConcat", + "parallelUploadAbortCleanup", }, "covered-by-generated-scenario" ), - "Split one input into partial uploads and concatenate their upload URLs.", + "Split one input into partial uploads, run the parts concurrently, clean up aborted parts, and concatenate their upload URLs.", "parallelUploadConcat", new GeneratedTusClientFeatureFlowStep[] { new GeneratedTusClientFeatureFlowStep( @@ -627,9 +670,11 @@ final class GeneratedTusProtocolContract { "patchTusUpload", }, new String[] { + "abort-current-request", "concatenate-partial-uploads", "emit-progress", "split-parallel-upload-boundaries", + "terminate-upload", } ), new GeneratedTusClientFeature( @@ -746,6 +791,7 @@ final class GeneratedTusProtocolContract { new GeneratedTusClientFeatureConformance( new String[] { "abortUpload", + "abortUploadAfterStoredUrl", }, "covered-by-generated-scenario" ), @@ -760,9 +806,12 @@ final class GeneratedTusProtocolContract { "Cancel in-flight transport work without emitting user callbacks after abort." ), }, - new String[0], + new String[] { + "terminateTusUpload", + }, new String[] { "abort-current-request", + "terminate-upload", } ), new GeneratedTusClientFeature( @@ -1024,6 +1073,7 @@ final class GeneratedTusProtocolContract { "startValidationParallelUploadsWithUploadUrl", "startValidationParallelUploadsWithUploadSize", "startValidationParallelUploadsWithDeferredLength", + "startValidationParallelUploadsWithUploadDataDuringCreation", "startValidationParallelBoundariesWithoutParallelUploads", "startValidationParallelBoundariesLengthMismatch", }, @@ -1071,6 +1121,9 @@ final class GeneratedTusProtocolContract { ), }; + static final GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = + GeneratedTusClientConformanceScenarios.CLIENT_CONFORMANCE_SCENARIOS; + private GeneratedTusProtocolContract() { } @@ -1239,4 +1292,49 @@ static final class GeneratedTusClientFeatureFlowStep { this.summary = summary; } } + + /** + * Generated client conformance scenario fixture. + */ + static final class GeneratedTusClientConformanceScenario { + final String behavior; + final String completionKind; + final String completionReason; + final String featureId; + final String scenarioId; + final String[] operationIds; + final String[] primitives; + final String[] eventKeys; + + GeneratedTusClientConformanceScenario( + String behavior, + GeneratedTusClientConformanceCompletion completion, + String featureId, + String scenarioId, + String[] operationIds, + String[] primitives, + String[] eventKeys) { + this.behavior = behavior; + this.completionKind = completion.kind; + this.completionReason = completion.reason; + this.featureId = featureId; + this.scenarioId = scenarioId; + this.operationIds = operationIds; + this.primitives = primitives; + this.eventKeys = eventKeys; + } + } + + /** + * Generated client conformance completion fixture. + */ + static final class GeneratedTusClientConformanceCompletion { + final String kind; + final String reason; + + GeneratedTusClientConformanceCompletion(String kind, String reason) { + this.kind = kind; + this.reason = reason; + } + } } From 30c5397ffc91928fa02969d21438a53ef5d80bcb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 07:42:17 +0200 Subject: [PATCH 17/63] Add generated TUS conformance scenarios --- ...eneratedTusClientConformanceScenarios.java | 714 ++++++++++++++++++ 1 file changed, 714 insertions(+) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java new file mode 100644 index 0000000..4f37412 --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java @@ -0,0 +1,714 @@ +/* + * Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. + * If it looks wrong, please report the issue instead of editing this file by hand; + * the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + */ + +package io.tus.android.client; + +/** + * Generated TUS client conformance scenario fixture used by tests. + */ +final class GeneratedTusClientConformanceScenarios { + static final GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario[] { + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "single-upload-lifecycle", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "singleUploadLifecycle", + "singleUploadLifecycle", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "open-input-source", + "fingerprint-input", + "store-resume-url", + "retry-with-backoff", + "emit-progress", + "abort-current-request", + }, + new String[] { + "fingerprint:contract-single-fingerprint", + "upload-url-available", + "url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "creation-with-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "creationWithUpload", + "creationWithUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + }, + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "creation-with-upload-partial-chunk", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "creationWithUpload", + "creationWithUploadPartialChunk", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "upload-during-creation", + "emit-progress", + }, + new String[] { + "progress:0:11", + "progress:5:11", + "upload-url-available", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "creation-with-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "protocolVersionSelection", + "ietfDraft05CreationWithUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "upload-body-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "protocolVersionSelection", + "ietfDraft03ResumeWithoutKnownLength", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new String[] { + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "missingInput" + ), + "startOptionValidation", + "startValidationMissingInput", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "missingEndpointOrUploadUrl" + ), + "startOptionValidation", + "startValidationMissingEndpointOrUploadUrl", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "unsupportedProtocol" + ), + "startOptionValidation", + "startValidationUnsupportedProtocol", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "retryDelaysNotArray" + ), + "startOptionValidation", + "startValidationRetryDelaysNotArray", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithUploadUrl" + ), + "startOptionValidation", + "startValidationParallelUploadsWithUploadUrl", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithUploadSize" + ), + "startOptionValidation", + "startValidationParallelUploadsWithUploadSize", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithDeferredLength" + ), + "startOptionValidation", + "startValidationParallelUploadsWithDeferredLength", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelUploadsWithUploadDataDuringCreation" + ), + "startOptionValidation", + "startValidationParallelUploadsWithUploadDataDuringCreation", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelBoundariesWithoutParallelUploads" + ), + "startOptionValidation", + "startValidationParallelBoundariesWithoutParallelUploads", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "start-option-validation", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "parallelBoundariesLengthMismatch" + ), + "startOptionValidation", + "startValidationParallelBoundariesLengthMismatch", + new String[0], + new String[] { + "validate-start-options", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "detailed-error", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "unexpectedCreateResponse" + ), + "detailedErrors", + "detailedCreateResponseError", + new String[] { + "createTusUpload", + }, + new String[] { + "report-detailed-errors", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "detailed-error", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "error", + "createUploadRequestFailed" + ), + "detailedErrors", + "detailedCreateRequestError", + new String[] { + "createTusUpload", + }, + new String[] { + "report-detailed-errors", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "upload-body-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "uploadBodyHeaders", + "uploadBodyHeaders", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "send-upload-body-headers", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "custom-request-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "customRequestHeaders", + "customRequestHeaders", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "apply-custom-request-headers", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "resume-from-previous-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "resumeUpload", + "resumeFromPreviousUpload", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "fingerprint-input", + "resume-from-previous-upload", + "store-resume-url", + }, + new String[] { + "fingerprint:contract-resume-fingerprint", + "url-storage-find:contract-resume-fingerprint:1", + "fingerprint:contract-resume-fingerprint", + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "url-storage-remove:tus::contract-resume-fingerprint::1337", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "relative-location-resolution", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "relativeLocationResolution", + "relativeLocationResolution", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "resolve-relative-location", + }, + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "array-buffer-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "arrayBufferInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-browser-file", + }, + new String[] { + "source-open:array-buffer:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "array-buffer-view-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "arrayBufferViewInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-browser-file", + }, + new String[] { + "source-open:array-buffer-view:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "web-readable-stream-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "webReadableStreamInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-web-stream", + }, + new String[] { + "source-open:web-readable-stream:null", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "node-readable-stream-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "nodeReadableStreamInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-node-stream", + }, + new String[] { + "source-open:node-readable-stream:null", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "node-path-input", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "inputSources", + "nodePathInput", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "read-node-file", + }, + new String[] { + "source-open:node-path-reference:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "deferred-length-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "deferredLengthUpload", + "deferredLengthUpload", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "defer-upload-length", + "emit-progress", + }, + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "override-patch-method", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "overridePatchMethod", + "overridePatchMethod", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "override-patch-method", + }, + new String[0] + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "parallel-upload-concat", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "parallelUploadConcat", + "parallelUploadConcat", + new String[] { + "createTusUpload", + "createTusUpload", + "patchTusUpload", + "patchTusUpload", + "createTusUpload", + }, + new String[] { + "concatenate-partial-uploads", + "emit-progress", + }, + new String[] { + "progress:5:11", + "chunk-complete:5:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "parallel-upload-abort-cleanup", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "aborted", + null + ), + "parallelUploadConcat", + "parallelUploadAbortCleanup", + new String[] { + "createTusUpload", + "createTusUpload", + "patchTusUpload", + "patchTusUpload", + "terminateTusUpload", + "terminateTusUpload", + }, + new String[] { + "abort-current-request", + "terminate-upload", + "concatenate-partial-uploads", + }, + new String[] { + "request-abort:3", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "retry-patch-after-offset-recovery", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "retryOffsetRecovery", + "retryPatchAfterOffsetRecovery", + new String[] { + "createTusUpload", + "patchTusUpload", + "getTusUploadOffset", + "patchTusUpload", + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "retry-with-backoff", + "recover-offset-after-error", + }, + new String[] { + "should-retry:0:true", + "retry-schedule:0", + "should-retry:0:true", + "retry-schedule:0", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "request-lifecycle-hooks", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "requestLifecycleHooks", + "requestLifecycleHooks", + new String[] { + "getTusUploadOffset", + }, + new String[] { + "run-request-hooks", + }, + new String[] { + "before-request:0", + "after-response:0", + "success", + "source-close", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "abort-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "aborted", + null + ), + "abortUpload", + "abortUpload", + new String[] { + "createTusUpload", + }, + new String[] { + "abort-current-request", + }, + new String[] { + "request-abort:0", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "abort-upload-after-stored-url", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "aborted", + null + ), + "abortUpload", + "abortUploadAfterStoredUrl", + new String[] { + "createTusUpload", + "patchTusUpload", + "terminateTusUpload", + }, + new String[] { + "abort-current-request", + "terminate-upload", + }, + new String[] { + "request-abort:1", + } + ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "terminate-with-retry", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "terminated", + null + ), + "terminateUpload", + "terminateWithRetry", + new String[] { + "createTusUpload", + "patchTusUpload", + "terminateTusUpload", + "terminateTusUpload", + }, + new String[] { + "terminate-upload", + "retry-with-backoff", + }, + new String[0] + ), + }; + + private GeneratedTusClientConformanceScenarios() { + } +} From bda70374d4d93e58e602c525c96b84570f2ae534 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 07:57:42 +0200 Subject: [PATCH 18/63] Regenerate TUS event contract --- .../android/client/GeneratedTusClientConformanceScenarios.java | 1 + 1 file changed, 1 insertion(+) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java index 4f37412..31ac24b 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java @@ -86,6 +86,7 @@ final class GeneratedTusClientConformanceScenarios { "progress:0:11", "progress:5:11", "upload-url-available", + "chunk-complete:5:5:11", "progress:5:11", "progress:10:11", "chunk-complete:5:10:11", From 2b0959c94cfe3310036ae53fe0fcccdafc8f08e6 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 08:18:22 +0200 Subject: [PATCH 19/63] Carry generated TUS event policy --- ...eneratedTusClientConformanceScenarios.java | 175 ++++++++++++++++++ .../client/GeneratedTusProtocolContract.java | 21 +++ 2 files changed, 196 insertions(+) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java index 31ac24b..50cd183 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java @@ -32,6 +32,11 @@ final class GeneratedTusClientConformanceScenarios { "emit-progress", "abort-current-request", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "fingerprint:contract-single-fingerprint", "upload-url-available", @@ -58,6 +63,11 @@ final class GeneratedTusClientConformanceScenarios { "upload-during-creation", "emit-progress", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "progress:0:11", "progress:11:11", @@ -82,6 +92,11 @@ final class GeneratedTusClientConformanceScenarios { "upload-during-creation", "emit-progress", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "progress:0:11", "progress:5:11", @@ -111,6 +126,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "select-client-protocol", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "progress:0:11", "progress:11:11", @@ -134,6 +154,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "select-client-protocol", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "upload-url-available", "progress:5:11", @@ -155,6 +180,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -169,6 +199,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -183,6 +218,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -197,6 +237,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -211,6 +256,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -225,6 +275,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -239,6 +294,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -253,6 +313,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -267,6 +332,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -281,6 +351,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -297,6 +372,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "report-detailed-errors", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -313,6 +393,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "report-detailed-errors", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -330,6 +415,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "send-upload-body-headers", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -347,6 +437,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "apply-custom-request-headers", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -366,6 +461,11 @@ final class GeneratedTusClientConformanceScenarios { "resume-from-previous-upload", "store-resume-url", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "fingerprint:contract-resume-fingerprint", "url-storage-find:contract-resume-fingerprint:1", @@ -394,6 +494,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "resolve-relative-location", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "upload-url-available", "progress:0:11", @@ -418,6 +523,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-browser-file", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:array-buffer:11", "success", @@ -439,6 +549,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-browser-file", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:array-buffer-view:11", "success", @@ -460,6 +575,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-web-stream", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:web-readable-stream:null", "success", @@ -481,6 +601,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-node-stream", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:node-readable-stream:null", "success", @@ -502,6 +627,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-node-file", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "source-open:node-path-reference:11", "success", @@ -524,6 +654,11 @@ final class GeneratedTusClientConformanceScenarios { "defer-upload-length", "emit-progress", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "upload-url-available", "progress:0:11", @@ -548,6 +683,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "override-patch-method", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -569,6 +709,11 @@ final class GeneratedTusClientConformanceScenarios { "concatenate-partial-uploads", "emit-progress", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), new String[] { "progress:5:11", "chunk-complete:5:5:11", @@ -597,6 +742,11 @@ final class GeneratedTusClientConformanceScenarios { "terminate-upload", "concatenate-partial-uploads", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "request-abort:3", } @@ -621,6 +771,11 @@ final class GeneratedTusClientConformanceScenarios { "retry-with-backoff", "recover-offset-after-error", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "should-retry:0:true", "retry-schedule:0", @@ -642,6 +797,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "run-request-hooks", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "before-request:0", "after-response:0", @@ -663,6 +823,11 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "abort-current-request", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "request-abort:0", } @@ -684,6 +849,11 @@ final class GeneratedTusClientConformanceScenarios { "abort-current-request", "terminate-upload", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[] { "request-abort:1", } @@ -706,6 +876,11 @@ final class GeneratedTusClientConformanceScenarios { "terminate-upload", "retry-with-backoff", }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), new String[0] ), }; diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 8ba8b05..791fcfb 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1304,6 +1304,7 @@ static final class GeneratedTusClientConformanceScenario { final String scenarioId; final String[] operationIds; final String[] primitives; + final GeneratedTusClientConformanceEventPolicy eventPolicy; final String[] eventKeys; GeneratedTusClientConformanceScenario( @@ -1313,6 +1314,7 @@ static final class GeneratedTusClientConformanceScenario { String scenarioId, String[] operationIds, String[] primitives, + GeneratedTusClientConformanceEventPolicy eventPolicy, String[] eventKeys) { this.behavior = behavior; this.completionKind = completion.kind; @@ -1321,10 +1323,29 @@ static final class GeneratedTusClientConformanceScenario { this.scenarioId = scenarioId; this.operationIds = operationIds; this.primitives = primitives; + this.eventPolicy = eventPolicy; this.eventKeys = eventKeys; } } + /** + * Generated client conformance event policy fixture. + */ + static final class GeneratedTusClientConformanceEventPolicy { + final String matching; + final String progress; + final String transportProgress; + + GeneratedTusClientConformanceEventPolicy( + String matching, + String progress, + String transportProgress) { + this.matching = matching; + this.progress = progress; + this.transportProgress = transportProgress; + } + } + /** * Generated client conformance completion fixture. */ From 102bb9d534a43f37912527027da70b7af4327b36 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 08:27:25 +0200 Subject: [PATCH 20/63] Keep generated event fixtures lintable --- ...eneratedTusClientConformanceScenarios.java | 704 ++++++++++-------- .../client/GeneratedTusProtocolContract.java | 22 +- 2 files changed, 405 insertions(+), 321 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java index 50cd183..ee92f71 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java @@ -32,21 +32,23 @@ final class GeneratedTusClientConformanceScenarios { "emit-progress", "abort-current-request", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "fingerprint:contract-single-fingerprint", - "upload-url-available", - "url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract", - "progress:0:11", - "progress:11:11", - "chunk-complete:11:11:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "fingerprint:contract-single-fingerprint", + "upload-url-available", + "url-storage-add:contract-single-fingerprint:https://tus.io/uploads/generated-contract", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "creation-with-upload", @@ -63,18 +65,20 @@ final class GeneratedTusClientConformanceScenarios { "upload-during-creation", "emit-progress", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "progress:0:11", - "progress:11:11", - "upload-url-available", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "creation-with-upload-partial-chunk", @@ -92,25 +96,27 @@ final class GeneratedTusClientConformanceScenarios { "upload-during-creation", "emit-progress", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "progress:0:11", - "progress:5:11", - "upload-url-available", - "chunk-complete:5:5:11", - "progress:5:11", - "progress:10:11", - "chunk-complete:5:10:11", - "progress:10:11", - "progress:11:11", - "chunk-complete:1:11:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:5:11", + "upload-url-available", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "creation-with-upload", @@ -126,18 +132,20 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "select-client-protocol", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "progress:0:11", - "progress:11:11", - "upload-url-available", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:0:11", + "progress:11:11", + "upload-url-available", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "upload-body-headers", @@ -154,19 +162,21 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "select-client-protocol", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "upload-url-available", - "progress:5:11", - "progress:11:11", - "chunk-complete:6:11:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -180,12 +190,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -199,12 +211,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -218,12 +232,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -237,12 +253,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -256,12 +274,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -275,12 +295,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -294,12 +316,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -313,12 +337,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -332,12 +358,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "start-option-validation", @@ -351,12 +379,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "validate-start-options", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "detailed-error", @@ -372,12 +402,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "report-detailed-errors", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "detailed-error", @@ -393,12 +425,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "report-detailed-errors", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "upload-body-headers", @@ -415,12 +449,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "send-upload-body-headers", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "custom-request-headers", @@ -437,12 +473,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "apply-custom-request-headers", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "resume-from-previous-upload", @@ -461,23 +499,25 @@ final class GeneratedTusClientConformanceScenarios { "resume-from-previous-upload", "store-resume-url", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "fingerprint:contract-resume-fingerprint", - "url-storage-find:contract-resume-fingerprint:1", - "fingerprint:contract-resume-fingerprint", - "upload-url-available", - "progress:5:11", - "progress:11:11", - "chunk-complete:6:11:11", - "url-storage-remove:tus::contract-resume-fingerprint::1337", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "fingerprint:contract-resume-fingerprint", + "url-storage-find:contract-resume-fingerprint:1", + "fingerprint:contract-resume-fingerprint", + "upload-url-available", + "progress:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + "url-storage-remove:tus::contract-resume-fingerprint::1337", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "relative-location-resolution", @@ -494,19 +534,21 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "resolve-relative-location", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "upload-url-available", - "progress:0:11", - "progress:11:11", - "chunk-complete:11:11:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "array-buffer-input", @@ -523,16 +565,18 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-browser-file", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "source-open:array-buffer:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:array-buffer:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "array-buffer-view-input", @@ -549,16 +593,18 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-browser-file", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "source-open:array-buffer-view:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:array-buffer-view:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "web-readable-stream-input", @@ -575,16 +621,18 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-web-stream", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "source-open:web-readable-stream:null", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:web-readable-stream:null", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "node-readable-stream-input", @@ -601,16 +649,18 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-node-stream", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "source-open:node-readable-stream:null", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:node-readable-stream:null", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "node-path-input", @@ -627,16 +677,18 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "read-node-file", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "source-open:node-path-reference:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "source-open:node-path-reference:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "deferred-length-upload", @@ -654,19 +706,21 @@ final class GeneratedTusClientConformanceScenarios { "defer-upload-length", "emit-progress", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "upload-url-available", - "progress:0:11", - "progress:11:11", - "chunk-complete:11:11:11", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:11:11", + "chunk-complete:11:11:11", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "override-patch-method", @@ -683,12 +737,14 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "override-patch-method", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "parallel-upload-concat", @@ -709,17 +765,19 @@ final class GeneratedTusClientConformanceScenarios { "concatenate-partial-uploads", "emit-progress", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", - "milestone", - "may-emit-extra-samples" - ), - new String[] { - "progress:5:11", - "chunk-complete:5:5:11", - "progress:11:11", - "chunk-complete:6:11:11", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "progress:5:11", + "chunk-complete:5:5:11", + "progress:11:11", + "chunk-complete:6:11:11", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "parallel-upload-abort-cleanup", @@ -742,14 +800,16 @@ final class GeneratedTusClientConformanceScenarios { "terminate-upload", "concatenate-partial-uploads", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "request-abort:3", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "request-abort:3", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "retry-patch-after-offset-recovery", @@ -771,17 +831,19 @@ final class GeneratedTusClientConformanceScenarios { "retry-with-backoff", "recover-offset-after-error", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "should-retry:0:true", - "retry-schedule:0", - "should-retry:0:true", - "retry-schedule:0", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "should-retry:0:true", + "retry-schedule:0", + "should-retry:0:true", + "retry-schedule:0", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "request-lifecycle-hooks", @@ -797,17 +859,19 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "run-request-hooks", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "before-request:0", - "after-response:0", - "success", - "source-close", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "before-request:0", + "after-response:0", + "success", + "source-close", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "abort-upload", @@ -823,14 +887,16 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "abort-current-request", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "request-abort:0", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "request-abort:0", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "abort-upload-after-stored-url", @@ -849,14 +915,16 @@ final class GeneratedTusClientConformanceScenarios { "abort-current-request", "terminate-upload", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[] { - "request-abort:1", - } + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[] { + "request-abort:1", + } + ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "terminate-with-retry", @@ -876,12 +944,14 @@ final class GeneratedTusClientConformanceScenarios { "terminate-upload", "retry-with-backoff", }, - new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact", - null, - null - ), - new String[0] + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) ), }; diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 791fcfb..7fde4df 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1314,8 +1314,7 @@ static final class GeneratedTusClientConformanceScenario { String scenarioId, String[] operationIds, String[] primitives, - GeneratedTusClientConformanceEventPolicy eventPolicy, - String[] eventKeys) { + GeneratedTusClientConformanceEvents events) { this.behavior = behavior; this.completionKind = completion.kind; this.completionReason = completion.reason; @@ -1323,8 +1322,23 @@ static final class GeneratedTusClientConformanceScenario { this.scenarioId = scenarioId; this.operationIds = operationIds; this.primitives = primitives; - this.eventPolicy = eventPolicy; - this.eventKeys = eventKeys; + this.eventPolicy = events.policy; + this.eventKeys = events.keys; + } + } + + /** + * Generated client conformance event fixture bundle. + */ + static final class GeneratedTusClientConformanceEvents { + final GeneratedTusClientConformanceEventPolicy policy; + final String[] keys; + + GeneratedTusClientConformanceEvents( + GeneratedTusClientConformanceEventPolicy policy, + String[] keys) { + this.policy = policy; + this.keys = keys; } } From ed60d2cd15c40e14e464a82ad8158cc5fd9057fa Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 09:25:16 +0200 Subject: [PATCH 21/63] Update generated TUS retry events --- .../client/GeneratedTusClientConformanceScenarios.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java index ee92f71..d1ec0d5 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java @@ -950,7 +950,10 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[] { + "should-retry:0:true", + "retry-schedule:0", + } ) ), }; From 0cc01e6ecf757cf5da77b629cf00aecdb868c6a3 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:37:34 +0200 Subject: [PATCH 22/63] Expose TUS managed upload contract --- .../io/tus/android/client/GeneratedTusProtocolContract.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 7fde4df..c1c761d 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1121,6 +1121,8 @@ final class GeneratedTusProtocolContract { ), }; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = GeneratedTusClientConformanceScenarios.CLIENT_CONFORMANCE_SCENARIOS; From 2a261c1fdc67afbec7fb31dfe78b051fd79a3e04 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 11:52:40 +0200 Subject: [PATCH 23/63] Expose managed upload proof cases --- .../client/GeneratedTusProtocolContract.java | 132 ++++++++++++++++++ .../GeneratedTusProtocolContractTest.java | 18 +++ 2 files changed, 150 insertions(+) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index c1c761d..e7c2701 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1123,6 +1123,111 @@ final class GeneratedTusProtocolContract { static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String[] MANAGED_UPLOAD_PRIMITIVES = + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload", + }; + + static final String[] MANAGED_UPLOAD_RUNTIME_PROFILES = + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + }; + + static final String[] MANAGED_UPLOAD_SCENARIO_IDS = + new String[] { + "managedUploadDurableRetry", + "managedUploadPermanentFailure", + "managedUploadNetworkConstraint", + }; + + static final GeneratedTusManagedUploadProofCase[] MANAGED_UPLOAD_PROOF_CASES = + new GeneratedTusManagedUploadProofCase[] { + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadDurableRetry", + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "publish-upload-state", + "cleanup-managed-upload", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadPermanentFailure", + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "classify-failure", + "publish-upload-state", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadNetworkConstraint", + new String[] { + "accept-upload-submission", + "schedule-upload-work", + "publish-upload-state", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), + }; + static final GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = GeneratedTusClientConformanceScenarios.CLIENT_CONFORMANCE_SCENARIOS; @@ -1295,6 +1400,33 @@ static final class GeneratedTusClientFeatureFlowStep { } } + /** + * Generated managed-upload feature proof fixture. + */ + static final class GeneratedTusManagedUploadProofCase { + final String featureId; + final String layer; + final String scenarioId; + final String[] requiredPrimitives; + final String[] protocolFeatureIds; + final String[] runtimeProfiles; + + GeneratedTusManagedUploadProofCase( + String featureId, + String layer, + String scenarioId, + String[] requiredPrimitives, + String[] protocolFeatureIds, + String[] runtimeProfiles) { + this.featureId = featureId; + this.layer = layer; + this.scenarioId = scenarioId; + this.requiredPrimitives = requiredPrimitives; + this.protocolFeatureIds = protocolFeatureIds; + this.runtimeProfiles = runtimeProfiles; + } + } + /** * Generated client conformance scenario fixture. */ diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContractTest.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContractTest.java index b87b2ac..20e4bb2 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContractTest.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContractTest.java @@ -33,6 +33,24 @@ public void shouldCoverResumeStoragePrimitive() throws Exception { assertEquals(url, store.get("fingerprint")); } + @Test + public void shouldCoverManagedUploadProofCases() { + for (GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase proofCase + : GeneratedTusProtocolContract.MANAGED_UPLOAD_PROOF_CASES) { + assertEquals("managedUpload", proofCase.featureId); + assertEquals("feature-over-protocol", proofCase.layer); + assertContains( + GeneratedTusProtocolContract.MANAGED_UPLOAD_SCENARIO_IDS, + proofCase.scenarioId); + for (String primitive : proofCase.requiredPrimitives) { + assertContains(GeneratedTusProtocolContract.MANAGED_UPLOAD_PRIMITIVES, primitive); + } + for (String featureId : proofCase.protocolFeatureIds) { + findFeature(featureId); + } + } + } + private static GeneratedTusProtocolContract.GeneratedTusClientFeature findFeature( String featureId) { for (GeneratedTusProtocolContract.GeneratedTusClientFeature feature From 846f9dbc60dbcbacff9e61b3a9922e8babeccea6 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 12:17:07 +0200 Subject: [PATCH 24/63] Update managed upload proof fixture --- .../io/tus/android/client/GeneratedTusProtocolContract.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index e7c2701..da8449a 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1121,7 +1121,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proof\": {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"stateBackend\": \"filesystem\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ]\n },\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { From abde825792ec7bb1e0962c62fd3278d8e86684a1 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 12:43:36 +0200 Subject: [PATCH 25/63] Add managed upload runtime proof --- .../client/GeneratedTusProtocolContract.java | 11 +- .../TestGeneratedTusManagedUploadRuntime.java | 801 ++++++++++++++++++ 2 files changed, 811 insertions(+), 1 deletion(-) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index da8449a..be3de63 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1121,7 +1121,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proof\": {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"stateBackend\": \"filesystem\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ]\n },\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { @@ -1158,6 +1158,10 @@ final class GeneratedTusProtocolContract { "managedUpload", "feature-over-protocol", "managedUploadDurableRetry", + new String[] { + "java", + "android", + }, new String[] { "accept-upload-submission", "make-source-durable", @@ -1184,6 +1188,7 @@ final class GeneratedTusProtocolContract { "managedUpload", "feature-over-protocol", "managedUploadPermanentFailure", + new String[0], new String[] { "accept-upload-submission", "make-source-durable", @@ -1208,6 +1213,7 @@ final class GeneratedTusProtocolContract { "managedUpload", "feature-over-protocol", "managedUploadNetworkConstraint", + new String[0], new String[] { "accept-upload-submission", "schedule-upload-work", @@ -1407,6 +1413,7 @@ static final class GeneratedTusManagedUploadProofCase { final String featureId; final String layer; final String scenarioId; + final String[] proofRuntimes; final String[] requiredPrimitives; final String[] protocolFeatureIds; final String[] runtimeProfiles; @@ -1415,12 +1422,14 @@ static final class GeneratedTusManagedUploadProofCase { String featureId, String layer, String scenarioId, + String[] proofRuntimes, String[] requiredPrimitives, String[] protocolFeatureIds, String[] runtimeProfiles) { this.featureId = featureId; this.layer = layer; this.scenarioId = scenarioId; + this.proofRuntimes = proofRuntimes; this.requiredPrimitives = requiredPrimitives; this.protocolFeatureIds = protocolFeatureIds; this.runtimeProfiles = runtimeProfiles; diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java new file mode 100644 index 0000000..0e8fe6a --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -0,0 +1,801 @@ +/* + * Code generated from Transloadit API2 TUS protocol contracts; DO NOT EDIT. + * If it looks wrong, please report the issue instead of editing this file by hand; + * the source fix belongs in the protocol contract generator so all TUS clients stay in sync. + */ + +package io.tus.android.client; + +import android.app.Activity; +import android.content.SharedPreferences; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusExecutor; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Tests generated Android managed-upload scenarios against Android storage and Java client pieces. + */ +@RunWith(RobolectricTestRunner.class) +public class TestGeneratedTusManagedUploadRuntime { + private static final GeneratedTusManagedUploadRuntimeCase[] CASES = + new GeneratedTusManagedUploadRuntimeCase[] { + new GeneratedTusManagedUploadRuntimeCase( + "managedUploadDurableRetry", + new GeneratedTusManagedUploadRuntimeProfile( + "android", + "durable-os-scheduler", + "copy-to-owned-storage", + "platform-key-value-store" + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadCleanup( + "remove-owned-source-after-success", + "remove-after-success" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + "running", + "failed", + "running", + "succeeded", + }, + new int[] { + 0, + } + ), + new GeneratedTusManagedUploadInput( + "hello managed!", + 7, + "managed-durable-retry-fingerprint", + "managed-durable-retry", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "failed", + new GeneratedTusManagedUploadFailure( + "io-error", + 7 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 201, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Location", + "https://tus.io/uploads/managed-durable-retry" + ), + } + ), + new GeneratedTusManagedUploadRequest( + "PATCH", + "upload", + 7, + 204, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "0" + ), + }, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + } + ), + } + ), + new GeneratedTusManagedUploadAttempt( + 1, + "succeeded", + null, + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "HEAD", + "upload", + 0, + 200, + new GeneratedTusManagedUploadHeader[0], + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + } + ), + new GeneratedTusManagedUploadRequest( + "PATCH", + "upload", + 7, + 204, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + }, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "14" + ), + } + ), + } + ), + } + ), + }; + private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = + new GeneratedTusMethodOverride[] { + new GeneratedTusMethodOverride( + "PATCH", + "POST", + "X-HTTP-Method-Override", + "PATCH" + ), + }; + + /** + * Verifies Android managed uploads can persist state and resume through platform storage. + */ + @Test + public void shouldRunManagedUploadWithAndroidPlatformState() throws Exception { + for (GeneratedTusManagedUploadRuntimeCase testCase : CASES) { + Activity activity = Robolectric.setupActivity(Activity.class); + SharedPreferences stateStore = + activity.getSharedPreferences(testCase.scenarioId + "-state", 0); + SharedPreferences urlStorePreferences = + activity.getSharedPreferences(testCase.scenarioId + "-urls", 0); + assertTrue(testCase.scenarioId, stateStore.edit().clear().commit()); + assertTrue(testCase.scenarioId, urlStorePreferences.edit().clear().commit()); + + GeneratedTusManagedUploadServer server = new GeneratedTusManagedUploadServer(testCase); + server.start(); + try { + List states = new ArrayList(); + File source = writeSourceFile(testCase); + File ownedSource = ownedSourceFile(testCase, source); + copyDurableSource(testCase, source, ownedSource); + recordState(testCase, states, stateStore, "pending"); + + final TusPreferencesURLStore urlStore = + new TusPreferencesURLStore(urlStorePreferences); + final TusClient client = new TusClient(); + client.setUploadCreationURL(server.endpointUrlFor(testCase)); + client.enableResuming(urlStore); + client.enableRemoveFingerprintOnSuccess(); + + TusExecutor executor = + managedExecutorFor(testCase, client, ownedSource, states, stateStore); + GeneratedTusAndroidScheduler scheduler = + new GeneratedTusAndroidScheduler(testCase, stateStore); + try { + Future future = scheduler.submit(new Callable() { + @Override + public Boolean call() throws Exception { + return executor.makeAttempts(); + } + }); + assertTrue(testCase.scenarioId, future.get()); + } finally { + scheduler.shutdown(); + } + + cleanupAfterSuccess(testCase, ownedSource); + + assertArrayEquals( + testCase.scenarioId, + testCase.expectedStates, + states.toArray(new String[states.size()])); + assertArrayEquals( + testCase.scenarioId, + testCase.expectedStates, + storedStates(stateStore)); + assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); + assertFalse(testCase.scenarioId, ownedSource.exists()); + assertTrue(testCase.scenarioId, source.exists()); + source.delete(); + } finally { + server.stop(); + } + } + } + + private TusExecutor managedExecutorFor( + final GeneratedTusManagedUploadRuntimeCase testCase, + final TusClient client, + final File ownedSource, + final List states, + final SharedPreferences stateStore) { + TusExecutor executor = new TusExecutor() { + private int attemptIndex; + + @Override + protected void makeAttempt() throws ProtocolException, IOException { + GeneratedTusManagedUploadAttempt attempt = testCase.attempts[attemptIndex]; + attemptIndex += 1; + recordState(testCase, states, stateStore, "running"); + + TusUpload upload = uploadFor(testCase, ownedSource); + TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(testCase.input.chunkSize); + uploader.setRequestPayloadSize(testCase.input.chunkSize); + while (uploader.getOffset() < upload.getSize()) { + uploader.uploadChunk(); + if ( + attempt.failure != null + && uploader.getOffset() == attempt.failure.afterAcceptedOffset) { + uploader.finish(false); + recordState(testCase, states, stateStore, attempt.stateAfterAttempt); + throw new IOException(attempt.failure.kind); + } + } + uploader.finish(); + recordState(testCase, states, stateStore, attempt.stateAfterAttempt); + } + }; + executor.setDelays(testCase.retryDelays); + return executor; + } + + private TusUpload uploadFor( + GeneratedTusManagedUploadRuntimeCase testCase, + File ownedSource) throws IOException { + TusUpload upload = new TusUpload(ownedSource); + upload.setFingerprint(testCase.input.fingerprint); + upload.setMetadata(metadataFor(testCase.input.metadata)); + return upload; + } + + private Map metadataFor(GeneratedTusManagedUploadMetadata[] metadata) { + Map result = new LinkedHashMap(); + for (GeneratedTusManagedUploadMetadata entry : metadata) { + result.put(entry.name, entry.value); + } + return result; + } + + private void copyDurableSource( + GeneratedTusManagedUploadRuntimeCase testCase, + File source, + File ownedSource) throws IOException { + if (!"copy-to-owned-storage".equals(testCase.sourceDurability)) { + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated source durability " + + testCase.sourceDurability); + } + + copyFile(source, ownedSource); + assertTrue(testCase.scenarioId, ownedSource.exists()); + } + + private void cleanupAfterSuccess( + GeneratedTusManagedUploadRuntimeCase testCase, + File ownedSource) throws IOException { + if (!"remove-owned-source-after-success".equals(testCase.ownedSourceCleanup)) { + return; + } + + if (ownedSource.exists() && !ownedSource.delete()) { + throw new IOException("Could not delete generated owned source " + ownedSource); + } + } + + private void recordState( + GeneratedTusManagedUploadRuntimeCase testCase, + List states, + SharedPreferences stateStore, + String state) { + if (!"platform-key-value-store".equals(testCase.stateBackend)) { + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated state backend " + + testCase.stateBackend); + } + + states.add(state); + SharedPreferences.Editor editor = stateStore.edit(); + editor.putInt("state-count", states.size()); + for (int index = 0; index < states.size(); index += 1) { + editor.putString("state-" + index, states.get(index)); + } + assertTrue(testCase.scenarioId, editor.commit()); + } + + private String[] storedStates(SharedPreferences stateStore) { + int count = stateStore.getInt("state-count", 0); + String[] states = new String[count]; + for (int index = 0; index < count; index += 1) { + states[index] = stateStore.getString("state-" + index, ""); + } + return states; + } + + private File writeSourceFile(GeneratedTusManagedUploadRuntimeCase testCase) throws IOException { + File source = File.createTempFile(testCase.scenarioId, "-source.bin"); + FileOutputStream output = new FileOutputStream(source); + try { + output.write(testCase.input.content.getBytes(StandardCharsets.UTF_8)); + } finally { + output.close(); + } + return source; + } + + private File ownedSourceFile( + GeneratedTusManagedUploadRuntimeCase testCase, + File source) { + return new File(source.getParentFile(), testCase.scenarioId + "-android-owned.bin"); + } + + private void copyFile(File source, File destination) throws IOException { + FileInputStream input = new FileInputStream(source); + try { + FileOutputStream output = new FileOutputStream(destination); + try { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + } finally { + output.close(); + } + } finally { + input.close(); + } + } + + private static String offsetDiscoveryMethod() { + for (GeneratedTusProtocolContract.GeneratedTusProtocolOperation operation + : GeneratedTusProtocolContract.OPERATIONS) { + if ("offset-discovery".equals(operation.role)) { + return operation.method; + } + } + + throw new AssertionError("Missing generated offset-discovery operation"); + } + + private static final class GeneratedTusAndroidScheduler { + private final ExecutorService worker = Executors.newSingleThreadExecutor(); + private final GeneratedTusManagedUploadRuntimeCase testCase; + private final SharedPreferences stateStore; + + GeneratedTusAndroidScheduler( + GeneratedTusManagedUploadRuntimeCase testCase, + SharedPreferences stateStore) { + this.testCase = testCase; + this.stateStore = stateStore; + } + + Future submit(Callable work) { + if (!"durable-os-scheduler".equals(testCase.scheduler)) { + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated scheduler " + + testCase.scheduler); + } + + assertTrue( + testCase.scenarioId, + stateStore.edit().putString("scheduler", testCase.scheduler).commit()); + return worker.submit(work); + } + + void shutdown() { + worker.shutdownNow(); + } + } + + private static final class GeneratedTusManagedUploadServer implements HttpHandler { + private final HttpServer server; + private final GeneratedTusManagedUploadRuntimeCase testCase; + + GeneratedTusManagedUploadServer(GeneratedTusManagedUploadRuntimeCase testCase) + throws IOException { + this.testCase = testCase; + this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + this.server.createContext("/", this); + } + + void start() { + server.start(); + } + + void stop() { + server.stop(0); + } + + URL endpointUrlFor(GeneratedTusManagedUploadRuntimeCase testCase) throws IOException { + return new URL("http://127.0.0.1:" + server.getAddress().getPort() + "/files"); + } + + URL uploadUrlFor(GeneratedTusManagedUploadRuntimeCase testCase) throws IOException { + return new URL(endpointUrlFor(testCase).toString() + "/" + testCase.input.uploadPath); + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + int bodySize = drainRequestBody(exchange); + GeneratedTusManagedUploadRequest request = findRequest(exchange, bodySize); + if (request == null) { + respondNotFound(exchange); + return; + } + + Headers responseHeaders = exchange.getResponseHeaders(); + for (GeneratedTusManagedUploadHeader header : request.responseHeaders) { + responseHeaders.add(header.name, responseHeaderValueFor(header)); + } + exchange.sendResponseHeaders(request.statusCode, -1); + exchange.close(); + } + + private GeneratedTusManagedUploadRequest findRequest( + HttpExchange exchange, + int bodySize) throws IOException { + for (GeneratedTusManagedUploadAttempt attempt : testCase.attempts) { + for (GeneratedTusManagedUploadRequest request : attempt.requests) { + if (matchesRequest(exchange, bodySize, request)) { + return request; + } + } + } + + return null; + } + + private boolean matchesRequest( + HttpExchange exchange, + int bodySize, + GeneratedTusManagedUploadRequest request) throws IOException { + if (!pathFor(request).equals(exchange.getRequestURI().getPath())) { + return false; + } + if (bodySize != request.bodySize) { + return false; + } + if (!methodMatches(exchange, request)) { + return false; + } + for (GeneratedTusManagedUploadHeader header : request.requestHeaders) { + if (!header.value.equals(headerValue(exchange.getRequestHeaders(), header.name))) { + return false; + } + } + + return true; + } + + private boolean methodMatches( + HttpExchange exchange, + GeneratedTusManagedUploadRequest request) { + if (request.method.equals(exchange.getRequestMethod())) { + return true; + } + GeneratedTusMethodOverride methodOverride = methodOverrideFor(request.method); + return methodOverride != null + && methodOverride.method.equals(exchange.getRequestMethod()) + && methodOverride.headerValue.equals( + headerValue(exchange.getRequestHeaders(), methodOverride.headerName)); + } + + private String pathFor(GeneratedTusManagedUploadRequest request) throws IOException { + if ("endpoint".equals(request.url)) { + return endpointUrlFor(testCase).getPath(); + } + + return uploadUrlFor(testCase).getPath(); + } + + private String responseHeaderValueFor(GeneratedTusManagedUploadHeader header) + throws IOException { + if (!testCase.locationHeaderName.equals(header.name)) { + return header.value; + } + + return uploadUrlFor(testCase).toString(); + } + + private static int drainRequestBody(HttpExchange exchange) throws IOException { + int size = 0; + byte[] buffer = new byte[8192]; + int read; + while ((read = exchange.getRequestBody().read(buffer)) != -1) { + size += read; + } + return size; + } + + private static void respondNotFound(HttpExchange exchange) throws IOException { + byte[] body = "No generated request matched".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(404, body.length); + OutputStream output = exchange.getResponseBody(); + try { + output.write(body); + } finally { + output.close(); + } + } + + private static String headerValue(Headers headers, String name) { + for (Map.Entry> entry : headers.entrySet()) { + if (!entry.getKey().equalsIgnoreCase(name) || entry.getValue().isEmpty()) { + continue; + } + + return entry.getValue().get(0); + } + + return null; + } + + private static GeneratedTusMethodOverride methodOverrideFor(String originalMethod) { + for (GeneratedTusMethodOverride methodOverride : METHOD_OVERRIDES) { + if (methodOverride.originalMethod.equals(originalMethod)) { + return methodOverride; + } + } + + return null; + } + } + + private static final class GeneratedTusManagedUploadRuntimeCase { + final String scenarioId; + final String runtime; + final String scheduler; + final String sourceDurability; + final String stateBackend; + final String locationHeaderName; + final String ownedSourceCleanup; + final String resumeUrlCleanup; + final String[] expectedStates; + final int[] retryDelays; + final String offsetDiscoveryMethod; + final GeneratedTusManagedUploadInput input; + final GeneratedTusManagedUploadAttempt[] attempts; + + GeneratedTusManagedUploadRuntimeCase( + String scenarioId, + GeneratedTusManagedUploadRuntimeProfile profile, + GeneratedTusManagedUploadTransport transport, + GeneratedTusManagedUploadCleanup cleanup, + GeneratedTusManagedUploadRetryPlan retryPlan, + GeneratedTusManagedUploadInput input, + GeneratedTusManagedUploadAttempt[] attempts) { + this.scenarioId = scenarioId; + this.runtime = profile.runtime; + this.scheduler = profile.scheduler; + this.sourceDurability = profile.sourceDurability; + this.stateBackend = profile.stateBackend; + this.locationHeaderName = transport.locationHeaderName; + this.ownedSourceCleanup = cleanup.ownedSource; + this.resumeUrlCleanup = cleanup.resumeUrl; + this.expectedStates = retryPlan.expectedStates; + this.retryDelays = retryPlan.retryDelays; + this.offsetDiscoveryMethod = offsetDiscoveryMethod(); + this.input = input; + this.attempts = attempts; + } + } + + private static final class GeneratedTusManagedUploadRuntimeProfile { + final String runtime; + final String scheduler; + final String sourceDurability; + final String stateBackend; + + GeneratedTusManagedUploadRuntimeProfile( + String runtime, + String scheduler, + String sourceDurability, + String stateBackend) { + this.runtime = runtime; + this.scheduler = scheduler; + this.sourceDurability = sourceDurability; + this.stateBackend = stateBackend; + } + } + + private static final class GeneratedTusManagedUploadTransport { + final String locationHeaderName; + + GeneratedTusManagedUploadTransport(String locationHeaderName) { + this.locationHeaderName = locationHeaderName; + } + } + + private static final class GeneratedTusManagedUploadCleanup { + final String ownedSource; + final String resumeUrl; + + GeneratedTusManagedUploadCleanup(String ownedSource, String resumeUrl) { + this.ownedSource = ownedSource; + this.resumeUrl = resumeUrl; + } + } + + private static final class GeneratedTusManagedUploadRetryPlan { + final String[] expectedStates; + final int[] retryDelays; + + GeneratedTusManagedUploadRetryPlan(String[] expectedStates, int[] retryDelays) { + this.expectedStates = expectedStates; + this.retryDelays = retryDelays; + } + } + + private static final class GeneratedTusManagedUploadInput { + final String content; + final int chunkSize; + final String fingerprint; + final String uploadPath; + final GeneratedTusManagedUploadMetadata[] metadata; + + GeneratedTusManagedUploadInput( + String content, + int chunkSize, + String fingerprint, + String uploadPath, + GeneratedTusManagedUploadMetadata[] metadata) { + this.content = content; + this.chunkSize = chunkSize; + this.fingerprint = fingerprint; + this.uploadPath = uploadPath; + this.metadata = metadata; + } + } + + private static final class GeneratedTusManagedUploadAttempt { + final int attemptIndex; + final String stateAfterAttempt; + final GeneratedTusManagedUploadFailure failure; + final GeneratedTusManagedUploadRequest[] requests; + + GeneratedTusManagedUploadAttempt( + int attemptIndex, + String stateAfterAttempt, + GeneratedTusManagedUploadFailure failure, + GeneratedTusManagedUploadRequest[] requests) { + this.attemptIndex = attemptIndex; + this.stateAfterAttempt = stateAfterAttempt; + this.failure = failure; + this.requests = requests; + } + } + + private static final class GeneratedTusManagedUploadFailure { + final String kind; + final long afterAcceptedOffset; + + GeneratedTusManagedUploadFailure(String kind, long afterAcceptedOffset) { + this.kind = kind; + this.afterAcceptedOffset = afterAcceptedOffset; + } + } + + private static final class GeneratedTusManagedUploadRequest { + final String method; + final String url; + final int bodySize; + final int statusCode; + final GeneratedTusManagedUploadHeader[] requestHeaders; + final GeneratedTusManagedUploadHeader[] responseHeaders; + + GeneratedTusManagedUploadRequest( + String method, + String url, + int bodySize, + int statusCode, + GeneratedTusManagedUploadHeader[] requestHeaders, + GeneratedTusManagedUploadHeader[] responseHeaders) { + this.method = method; + this.url = url; + this.bodySize = bodySize; + this.statusCode = statusCode; + this.requestHeaders = requestHeaders; + this.responseHeaders = responseHeaders; + } + } + + private static final class GeneratedTusManagedUploadHeader { + final String name; + final String value; + + GeneratedTusManagedUploadHeader(String name, String value) { + this.name = name; + this.value = value; + } + } + + private static final class GeneratedTusManagedUploadMetadata { + final String name; + final String value; + + GeneratedTusManagedUploadMetadata(String name, String value) { + this.name = name; + this.value = value; + } + } + + private static final class GeneratedTusMethodOverride { + final String originalMethod; + final String method; + final String headerName; + final String headerValue; + + GeneratedTusMethodOverride( + String originalMethod, + String method, + String headerName, + String headerValue) { + this.originalMethod = originalMethod; + this.method = method; + this.headerName = headerName; + this.headerValue = headerValue; + } + } +} From 48189c9265433a5a8e6dc62da51e8cbdaf66e06e Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 12:50:40 +0200 Subject: [PATCH 26/63] Use Android-safe managed proof server --- .../TestGeneratedTusManagedUploadRuntime.java | 223 +++++++++++++----- 1 file changed, 168 insertions(+), 55 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 0e8fe6a..489edb2 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -9,17 +9,16 @@ import android.app.Activity; import android.content.SharedPreferences; -import com.sun.net.httpserver.Headers; -import com.sun.net.httpserver.HttpExchange; -import com.sun.net.httpserver.HttpHandler; -import com.sun.net.httpserver.HttpServer; - +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; -import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -449,56 +448,80 @@ void shutdown() { } } - private static final class GeneratedTusManagedUploadServer implements HttpHandler { - private final HttpServer server; + private static final class GeneratedTusManagedUploadServer { + private final ServerSocket serverSocket; private final GeneratedTusManagedUploadRuntimeCase testCase; + private volatile boolean running; + private Thread thread; GeneratedTusManagedUploadServer(GeneratedTusManagedUploadRuntimeCase testCase) throws IOException { this.testCase = testCase; - this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - this.server.createContext("/", this); + this.serverSocket = new ServerSocket(0); } void start() { - server.start(); + running = true; + thread = new Thread(new Runnable() { + @Override + public void run() { + serve(); + } + }); + thread.start(); } - void stop() { - server.stop(0); + void stop() throws IOException, InterruptedException { + running = false; + serverSocket.close(); + if (thread != null) { + thread.join(1000); + } } URL endpointUrlFor(GeneratedTusManagedUploadRuntimeCase testCase) throws IOException { - return new URL("http://127.0.0.1:" + server.getAddress().getPort() + "/files"); + return new URL("http://127.0.0.1:" + serverSocket.getLocalPort() + "/files"); } URL uploadUrlFor(GeneratedTusManagedUploadRuntimeCase testCase) throws IOException { return new URL(endpointUrlFor(testCase).toString() + "/" + testCase.input.uploadPath); } - @Override - public void handle(HttpExchange exchange) throws IOException { - int bodySize = drainRequestBody(exchange); - GeneratedTusManagedUploadRequest request = findRequest(exchange, bodySize); - if (request == null) { - respondNotFound(exchange); - return; + private void serve() { + while (running) { + try { + Socket socket = serverSocket.accept(); + handle(socket); + } catch (SocketException error) { + if (running) { + throw new AssertionError(error); + } + } catch (IOException error) { + throw new AssertionError(error); + } } + } - Headers responseHeaders = exchange.getResponseHeaders(); - for (GeneratedTusManagedUploadHeader header : request.responseHeaders) { - responseHeaders.add(header.name, responseHeaderValueFor(header)); + private void handle(Socket socket) throws IOException { + try { + GeneratedTusHttpRequest httpRequest = readHttpRequest(socket.getInputStream()); + GeneratedTusManagedUploadRequest request = findRequest(httpRequest); + if (request == null) { + respondNotFound(socket.getOutputStream()); + return; + } + + respond(socket.getOutputStream(), request); + } finally { + socket.close(); } - exchange.sendResponseHeaders(request.statusCode, -1); - exchange.close(); } - private GeneratedTusManagedUploadRequest findRequest( - HttpExchange exchange, - int bodySize) throws IOException { + private GeneratedTusManagedUploadRequest findRequest(GeneratedTusHttpRequest httpRequest) + throws IOException { for (GeneratedTusManagedUploadAttempt attempt : testCase.attempts) { for (GeneratedTusManagedUploadRequest request : attempt.requests) { - if (matchesRequest(exchange, bodySize, request)) { + if (matchesRequest(httpRequest, request)) { return request; } } @@ -508,20 +531,19 @@ private GeneratedTusManagedUploadRequest findRequest( } private boolean matchesRequest( - HttpExchange exchange, - int bodySize, + GeneratedTusHttpRequest httpRequest, GeneratedTusManagedUploadRequest request) throws IOException { - if (!pathFor(request).equals(exchange.getRequestURI().getPath())) { + if (!pathFor(request).equals(httpRequest.path)) { return false; } - if (bodySize != request.bodySize) { + if (httpRequest.bodySize != request.bodySize) { return false; } - if (!methodMatches(exchange, request)) { + if (!methodMatches(httpRequest, request)) { return false; } for (GeneratedTusManagedUploadHeader header : request.requestHeaders) { - if (!header.value.equals(headerValue(exchange.getRequestHeaders(), header.name))) { + if (!header.value.equals(headerValue(httpRequest.headers, header.name))) { return false; } } @@ -530,16 +552,16 @@ private boolean matchesRequest( } private boolean methodMatches( - HttpExchange exchange, + GeneratedTusHttpRequest httpRequest, GeneratedTusManagedUploadRequest request) { - if (request.method.equals(exchange.getRequestMethod())) { + if (request.method.equals(httpRequest.method)) { return true; } GeneratedTusMethodOverride methodOverride = methodOverrideFor(request.method); return methodOverride != null - && methodOverride.method.equals(exchange.getRequestMethod()) + && methodOverride.method.equals(httpRequest.method) && methodOverride.headerValue.equals( - headerValue(exchange.getRequestHeaders(), methodOverride.headerName)); + headerValue(httpRequest.headers, methodOverride.headerName)); } private String pathFor(GeneratedTusManagedUploadRequest request) throws IOException { @@ -559,28 +581,101 @@ private String responseHeaderValueFor(GeneratedTusManagedUploadHeader header) return uploadUrlFor(testCase).toString(); } - private static int drainRequestBody(HttpExchange exchange) throws IOException { - int size = 0; + private void respond(OutputStream output, GeneratedTusManagedUploadRequest request) + throws IOException { + StringBuilder response = new StringBuilder(); + response.append("HTTP/1.1 ").append(request.statusCode).append(" Generated\r\n"); + for (GeneratedTusManagedUploadHeader header : request.responseHeaders) { + response.append(header.name) + .append(": ") + .append(responseHeaderValueFor(header)) + .append("\r\n"); + } + response.append("Content-Length: 0\r\n"); + response.append("Connection: close\r\n"); + response.append("\r\n"); + output.write(response.toString().getBytes(StandardCharsets.UTF_8)); + } + + private GeneratedTusHttpRequest readHttpRequest(InputStream input) throws IOException { + ByteArrayOutputStream headerBytes = new ByteArrayOutputStream(); + int previousThird = -1; + int previousSecond = -1; + int previousFirst = -1; + int current; + while ((current = input.read()) != -1) { + headerBytes.write(current); + if ( + previousThird == '\r' + && previousSecond == '\n' + && previousFirst == '\r' + && current == '\n') { + break; + } + previousThird = previousSecond; + previousSecond = previousFirst; + previousFirst = current; + } + + String headerText = headerBytes.toString(StandardCharsets.UTF_8.name()); + String[] lines = headerText.split("\\r\\n"); + String[] requestLine = lines[0].split(" "); + Map> headers = new LinkedHashMap>(); + for (int index = 1; index < lines.length; index += 1) { + String line = lines[index]; + if (line.length() == 0) { + continue; + } + int separator = line.indexOf(":"); + if (separator < 0) { + continue; + } + String name = line.substring(0, separator); + String value = line.substring(separator + 1).trim(); + List values = headers.get(name); + if (values == null) { + values = new ArrayList(); + headers.put(name, values); + } + values.add(value); + } + + int bodySize = drainRequestBody(input, contentLength(headers)); + return new GeneratedTusHttpRequest(requestLine[0], requestLine[1], headers, bodySize); + } + + private static int contentLength(Map> headers) { + String header = headerValue(headers, "Content-Length"); + if (header == null || header.length() == 0) { + return 0; + } + + return Integer.parseInt(header); + } + + private static int drainRequestBody(InputStream input, int contentLength) + throws IOException { + int remaining = contentLength; byte[] buffer = new byte[8192]; - int read; - while ((read = exchange.getRequestBody().read(buffer)) != -1) { - size += read; + while (remaining > 0) { + int read = input.read(buffer, 0, Math.min(buffer.length, remaining)); + if (read == -1) { + break; + } + remaining -= read; } - return size; + return contentLength - remaining; } - private static void respondNotFound(HttpExchange exchange) throws IOException { + private static void respondNotFound(OutputStream output) throws IOException { byte[] body = "No generated request matched".getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(404, body.length); - OutputStream output = exchange.getResponseBody(); - try { - output.write(body); - } finally { - output.close(); - } + output.write("HTTP/1.1 404 Generated\r\n".getBytes(StandardCharsets.UTF_8)); + output.write(("Content-Length: " + body.length + "\r\n").getBytes(StandardCharsets.UTF_8)); + output.write("Connection: close\r\n\r\n".getBytes(StandardCharsets.UTF_8)); + output.write(body); } - private static String headerValue(Headers headers, String name) { + private static String headerValue(Map> headers, String name) { for (Map.Entry> entry : headers.entrySet()) { if (!entry.getKey().equalsIgnoreCase(name) || entry.getValue().isEmpty()) { continue; @@ -601,6 +696,24 @@ private static GeneratedTusMethodOverride methodOverrideFor(String originalMetho return null; } + + private static final class GeneratedTusHttpRequest { + final String method; + final String path; + final Map> headers; + final int bodySize; + + GeneratedTusHttpRequest( + String method, + String path, + Map> headers, + int bodySize) { + this.method = method; + this.path = path; + this.headers = headers; + this.bodySize = bodySize; + } + } } private static final class GeneratedTusManagedUploadRuntimeCase { From c603d2c8fe3d0ab5722ccfa53f88124629c73679 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 12:55:53 +0200 Subject: [PATCH 27/63] Handle chunked managed proof requests --- .../TestGeneratedTusManagedUploadRuntime.java | 64 +++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 489edb2..7ccca00 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -504,7 +504,8 @@ private void serve() { private void handle(Socket socket) throws IOException { try { - GeneratedTusHttpRequest httpRequest = readHttpRequest(socket.getInputStream()); + GeneratedTusHttpRequest httpRequest = + readHttpRequest(socket.getInputStream(), socket.getOutputStream()); GeneratedTusManagedUploadRequest request = findRequest(httpRequest); if (request == null) { respondNotFound(socket.getOutputStream()); @@ -597,7 +598,8 @@ private void respond(OutputStream output, GeneratedTusManagedUploadRequest reque output.write(response.toString().getBytes(StandardCharsets.UTF_8)); } - private GeneratedTusHttpRequest readHttpRequest(InputStream input) throws IOException { + private GeneratedTusHttpRequest readHttpRequest(InputStream input, OutputStream output) + throws IOException { ByteArrayOutputStream headerBytes = new ByteArrayOutputStream(); int previousThird = -1; int previousSecond = -1; @@ -640,7 +642,12 @@ private GeneratedTusHttpRequest readHttpRequest(InputStream input) throws IOExce values.add(value); } - int bodySize = drainRequestBody(input, contentLength(headers)); + if ("100-continue".equalsIgnoreCase(headerValue(headers, "Expect"))) { + output.write("HTTP/1.1 100 Continue\r\n\r\n".getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + int bodySize = drainRequestBody(input, headers); return new GeneratedTusHttpRequest(requestLine[0], requestLine[1], headers, bodySize); } @@ -653,7 +660,16 @@ private static int contentLength(Map> headers) { return Integer.parseInt(header); } - private static int drainRequestBody(InputStream input, int contentLength) + private static int drainRequestBody(InputStream input, Map> headers) + throws IOException { + if ("chunked".equalsIgnoreCase(headerValue(headers, "Transfer-Encoding"))) { + return drainChunkedRequestBody(input); + } + + return drainFixedRequestBody(input, contentLength(headers)); + } + + private static int drainFixedRequestBody(InputStream input, int contentLength) throws IOException { int remaining = contentLength; byte[] buffer = new byte[8192]; @@ -667,6 +683,46 @@ private static int drainRequestBody(InputStream input, int contentLength) return contentLength - remaining; } + private static int drainChunkedRequestBody(InputStream input) throws IOException { + int bodySize = 0; + while (true) { + String line = readAsciiLine(input); + int extensionIndex = line.indexOf(";"); + String sizeText = extensionIndex < 0 ? line : line.substring(0, extensionIndex); + int chunkSize = Integer.parseInt(sizeText.trim(), 16); + if (chunkSize == 0) { + drainChunkedTrailers(input); + return bodySize; + } + + bodySize += drainFixedRequestBody(input, chunkSize); + readAsciiLine(input); + } + } + + private static void drainChunkedTrailers(InputStream input) throws IOException { + while (true) { + String line = readAsciiLine(input); + if (line.length() == 0) { + return; + } + } + } + + private static String readAsciiLine(InputStream input) throws IOException { + ByteArrayOutputStream line = new ByteArrayOutputStream(); + int current; + while ((current = input.read()) != -1) { + if (current == '\n') { + break; + } + if (current != '\r') { + line.write(current); + } + } + return line.toString(StandardCharsets.UTF_8.name()); + } + private static void respondNotFound(OutputStream output) throws IOException { byte[] body = "No generated request matched".getBytes(StandardCharsets.UTF_8); output.write("HTTP/1.1 404 Generated\r\n".getBytes(StandardCharsets.UTF_8)); From 3880a677cb7815058c6055020b25cbac7e7a3fdb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:09:50 +0200 Subject: [PATCH 28/63] Add managed upload permanent failure proof --- .../client/GeneratedTusProtocolContract.java | 9 +- .../TestGeneratedTusManagedUploadRuntime.java | 230 ++++++++++++++++-- 2 files changed, 217 insertions(+), 22 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index be3de63..775e107 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1121,7 +1121,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { @@ -1188,13 +1188,18 @@ final class GeneratedTusProtocolContract { "managedUpload", "feature-over-protocol", "managedUploadPermanentFailure", - new String[0], + new String[] { + "java", + "android", + }, new String[] { "accept-upload-submission", "make-source-durable", "schedule-upload-work", + "run-protocol-upload", "classify-failure", "publish-upload-state", + "cleanup-managed-upload", }, new String[] { "singleUploadLifecycle", diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 7ccca00..d0c5e70 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -64,6 +65,10 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadTransport( "Location" ), + new GeneratedTusManagedUploadTerminal( + "succeeded", + "" + ), new GeneratedTusManagedUploadCleanup( "remove-owned-source-after-success", "remove-after-success" @@ -97,6 +102,7 @@ public class TestGeneratedTusManagedUploadRuntime { 0, "failed", new GeneratedTusManagedUploadFailure( + "after-accepted-offset", "io-error", 7 ), @@ -183,6 +189,72 @@ public class TestGeneratedTusManagedUploadRuntime { ), } ), + new GeneratedTusManagedUploadRuntimeCase( + "managedUploadPermanentFailure", + new GeneratedTusManagedUploadRuntimeProfile( + "android", + "durable-os-scheduler", + "copy-to-owned-storage", + "platform-key-value-store" + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadTerminal( + "failed", + "unretryable-protocol-error" + ), + new GeneratedTusManagedUploadCleanup( + "retain-owned-source-after-permanent-failure", + "absent-after-permanent-failure" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + "running", + "failed", + }, + new int[0] + ), + new GeneratedTusManagedUploadInput( + "hello failure!", + 7, + "managed-permanent-failure-fingerprint", + "managed-permanent-failure", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed-permanent-failure.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "failed", + new GeneratedTusManagedUploadFailure( + "during-protocol-request", + "unretryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 400, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[0] + ), + } + ), + } + ), }; private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = new GeneratedTusMethodOverride[] { @@ -235,12 +307,12 @@ public Boolean call() throws Exception { return executor.makeAttempts(); } }); - assertTrue(testCase.scenarioId, future.get()); + assertTerminalResult(testCase, future); } finally { scheduler.shutdown(); } - cleanupAfterSuccess(testCase, ownedSource); + cleanupAfterTerminalState(testCase, ownedSource); assertArrayEquals( testCase.scenarioId, @@ -250,8 +322,8 @@ public Boolean call() throws Exception { testCase.scenarioId, testCase.expectedStates, storedStates(stateStore)); - assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); - assertFalse(testCase.scenarioId, ownedSource.exists()); + assertResumeUrlState(testCase, urlStore); + assertOwnedSourceState(testCase, ownedSource); assertTrue(testCase.scenarioId, source.exists()); source.delete(); } finally { @@ -260,6 +332,47 @@ public Boolean call() throws Exception { } } + private void assertTerminalResult( + GeneratedTusManagedUploadRuntimeCase testCase, + Future future) throws Exception { + try { + boolean result = future.get(); + if (!"succeeded".equals(testCase.terminalState)) { + throw new AssertionError(testCase.scenarioId + " expected terminal failure"); + } + assertTrue(testCase.scenarioId, result); + } catch (ExecutionException error) { + if (!"failed".equals(testCase.terminalState)) { + throw error; + } + assertTerminalFailure(testCase, error.getCause()); + } + } + + private void assertTerminalFailure( + GeneratedTusManagedUploadRuntimeCase testCase, + Throwable error) { + if ("unretryable-protocol-error".equals(testCase.terminalFailure)) { + assertTrue(testCase.scenarioId, error instanceof ProtocolException); + return; + } + if ("source-unavailable".equals(testCase.terminalFailure)) { + assertTrue(testCase.scenarioId, error instanceof IOException); + return; + } + if ("retry-policy-exhausted".equals(testCase.terminalFailure)) { + assertTrue( + testCase.scenarioId, + error instanceof ProtocolException || error instanceof IOException); + return; + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated terminal failure " + + testCase.terminalFailure); + } + private TusExecutor managedExecutorFor( final GeneratedTusManagedUploadRuntimeCase testCase, final TusClient client, @@ -275,28 +388,53 @@ protected void makeAttempt() throws ProtocolException, IOException { attemptIndex += 1; recordState(testCase, states, stateStore, "running"); - TusUpload upload = uploadFor(testCase, ownedSource); - TusUploader uploader = client.resumeOrCreateUpload(upload); - uploader.setChunkSize(testCase.input.chunkSize); - uploader.setRequestPayloadSize(testCase.input.chunkSize); - while (uploader.getOffset() < upload.getSize()) { - uploader.uploadChunk(); - if ( - attempt.failure != null - && uploader.getOffset() == attempt.failure.afterAcceptedOffset) { - uploader.finish(false); - recordState(testCase, states, stateStore, attempt.stateAfterAttempt); - throw new IOException(attempt.failure.kind); + try { + TusUpload upload = uploadFor(testCase, ownedSource); + TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(testCase.input.chunkSize); + uploader.setRequestPayloadSize(testCase.input.chunkSize); + while (uploader.getOffset() < upload.getSize()) { + uploader.uploadChunk(); + if ( + isAfterAcceptedOffsetFailure(attempt) + && uploader.getOffset() == attempt.failure.afterAcceptedOffset) { + uploader.finish(false); + recordState(testCase, states, stateStore, attempt.stateAfterAttempt); + throw new IOException(attempt.failure.kind); + } } + uploader.finish(); + recordState(testCase, states, stateStore, attempt.stateAfterAttempt); + } catch (ProtocolException error) { + recordDuringProtocolFailure(testCase, states, stateStore, attempt); + throw error; + } catch (IOException error) { + recordDuringProtocolFailure(testCase, states, stateStore, attempt); + throw error; } - uploader.finish(); - recordState(testCase, states, stateStore, attempt.stateAfterAttempt); } }; executor.setDelays(testCase.retryDelays); return executor; } + private boolean isAfterAcceptedOffsetFailure(GeneratedTusManagedUploadAttempt attempt) { + return attempt.failure != null + && "after-accepted-offset".equals(attempt.failure.phase); + } + + private void recordDuringProtocolFailure( + GeneratedTusManagedUploadRuntimeCase testCase, + List states, + SharedPreferences stateStore, + GeneratedTusManagedUploadAttempt attempt) { + if (attempt.failure == null || !"during-protocol-request".equals(attempt.failure.phase)) { + return; + } + + recordState(testCase, states, stateStore, attempt.stateAfterAttempt); + } + private TusUpload uploadFor( GeneratedTusManagedUploadRuntimeCase testCase, File ownedSource) throws IOException { @@ -329,7 +467,7 @@ private void copyDurableSource( assertTrue(testCase.scenarioId, ownedSource.exists()); } - private void cleanupAfterSuccess( + private void cleanupAfterTerminalState( GeneratedTusManagedUploadRuntimeCase testCase, File ownedSource) throws IOException { if (!"remove-owned-source-after-success".equals(testCase.ownedSourceCleanup)) { @@ -341,6 +479,41 @@ private void cleanupAfterSuccess( } } + private void assertOwnedSourceState( + GeneratedTusManagedUploadRuntimeCase testCase, + File ownedSource) { + if ("remove-owned-source-after-success".equals(testCase.ownedSourceCleanup)) { + assertFalse(testCase.scenarioId, ownedSource.exists()); + return; + } + if ("retain-owned-source-after-permanent-failure".equals(testCase.ownedSourceCleanup)) { + assertTrue(testCase.scenarioId, ownedSource.exists()); + ownedSource.delete(); + return; + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated owned-source cleanup " + + testCase.ownedSourceCleanup); + } + + private void assertResumeUrlState( + GeneratedTusManagedUploadRuntimeCase testCase, + TusPreferencesURLStore urlStore) { + if ( + "remove-after-success".equals(testCase.resumeUrlCleanup) + || "absent-after-permanent-failure".equals(testCase.resumeUrlCleanup)) { + assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); + return; + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated resume URL cleanup " + + testCase.resumeUrlCleanup); + } + private void recordState( GeneratedTusManagedUploadRuntimeCase testCase, List states, @@ -779,6 +952,8 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final String sourceDurability; final String stateBackend; final String locationHeaderName; + final String terminalState; + final String terminalFailure; final String ownedSourceCleanup; final String resumeUrlCleanup; final String[] expectedStates; @@ -791,6 +966,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { String scenarioId, GeneratedTusManagedUploadRuntimeProfile profile, GeneratedTusManagedUploadTransport transport, + GeneratedTusManagedUploadTerminal terminal, GeneratedTusManagedUploadCleanup cleanup, GeneratedTusManagedUploadRetryPlan retryPlan, GeneratedTusManagedUploadInput input, @@ -801,6 +977,8 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.sourceDurability = profile.sourceDurability; this.stateBackend = profile.stateBackend; this.locationHeaderName = transport.locationHeaderName; + this.terminalState = terminal.state; + this.terminalFailure = terminal.failure; this.ownedSourceCleanup = cleanup.ownedSource; this.resumeUrlCleanup = cleanup.resumeUrl; this.expectedStates = retryPlan.expectedStates; @@ -811,6 +989,16 @@ private static final class GeneratedTusManagedUploadRuntimeCase { } } + private static final class GeneratedTusManagedUploadTerminal { + final String state; + final String failure; + + GeneratedTusManagedUploadTerminal(String state, String failure) { + this.state = state; + this.failure = failure; + } + } + private static final class GeneratedTusManagedUploadRuntimeProfile { final String runtime; final String scheduler; @@ -897,10 +1085,12 @@ private static final class GeneratedTusManagedUploadAttempt { } private static final class GeneratedTusManagedUploadFailure { + final String phase; final String kind; final long afterAcceptedOffset; - GeneratedTusManagedUploadFailure(String kind, long afterAcceptedOffset) { + GeneratedTusManagedUploadFailure(String phase, String kind, long afterAcceptedOffset) { + this.phase = phase; this.kind = kind; this.afterAcceptedOffset = afterAcceptedOffset; } From 63ec6f4a5820a3a5dff280758aef5097cd44d352 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:20:49 +0200 Subject: [PATCH 29/63] Respect managed upload fixture lint --- .../client/TestGeneratedTusManagedUploadRuntime.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index d0c5e70..248ee04 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -55,8 +55,8 @@ public class TestGeneratedTusManagedUploadRuntime { private static final GeneratedTusManagedUploadRuntimeCase[] CASES = new GeneratedTusManagedUploadRuntimeCase[] { new GeneratedTusManagedUploadRuntimeCase( - "managedUploadDurableRetry", new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadDurableRetry", "android", "durable-os-scheduler", "copy-to-owned-storage", @@ -190,8 +190,8 @@ public class TestGeneratedTusManagedUploadRuntime { } ), new GeneratedTusManagedUploadRuntimeCase( - "managedUploadPermanentFailure", new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadPermanentFailure", "android", "durable-os-scheduler", "copy-to-owned-storage", @@ -963,7 +963,6 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final GeneratedTusManagedUploadAttempt[] attempts; GeneratedTusManagedUploadRuntimeCase( - String scenarioId, GeneratedTusManagedUploadRuntimeProfile profile, GeneratedTusManagedUploadTransport transport, GeneratedTusManagedUploadTerminal terminal, @@ -971,7 +970,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { GeneratedTusManagedUploadRetryPlan retryPlan, GeneratedTusManagedUploadInput input, GeneratedTusManagedUploadAttempt[] attempts) { - this.scenarioId = scenarioId; + this.scenarioId = profile.scenarioId; this.runtime = profile.runtime; this.scheduler = profile.scheduler; this.sourceDurability = profile.sourceDurability; @@ -1000,16 +999,19 @@ private static final class GeneratedTusManagedUploadTerminal { } private static final class GeneratedTusManagedUploadRuntimeProfile { + final String scenarioId; final String runtime; final String scheduler; final String sourceDurability; final String stateBackend; GeneratedTusManagedUploadRuntimeProfile( + String scenarioId, String runtime, String scheduler, String sourceDurability, String stateBackend) { + this.scenarioId = scenarioId; this.runtime = runtime; this.scheduler = scheduler; this.sourceDurability = sourceDurability; From 6e041ebcf2aee188a24f4fce11a17ff61ceb3b32 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:32:43 +0200 Subject: [PATCH 30/63] Add managed upload retry exhaustion proof --- .../client/GeneratedTusProtocolContract.java | 34 ++++- .../TestGeneratedTusManagedUploadRuntime.java | 121 ++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 775e107..2117c95 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1121,7 +1121,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { @@ -1149,6 +1149,7 @@ final class GeneratedTusProtocolContract { new String[] { "managedUploadDurableRetry", "managedUploadPermanentFailure", + "managedUploadRetryPolicyExhausted", "managedUploadNetworkConstraint", }; @@ -1214,6 +1215,37 @@ final class GeneratedTusProtocolContract { "react-native", } ), + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadRetryPolicyExhausted", + new String[] { + "java", + "android", + }, + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "run-protocol-upload", + "apply-managed-retry-policy", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( "managedUpload", "feature-over-protocol", diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 248ee04..0f65087 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -255,6 +255,127 @@ public class TestGeneratedTusManagedUploadRuntime { ), } ), + new GeneratedTusManagedUploadRuntimeCase( + new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadRetryPolicyExhausted", + "android", + "durable-os-scheduler", + "copy-to-owned-storage", + "platform-key-value-store" + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadTerminal( + "failed", + "retry-policy-exhausted" + ), + new GeneratedTusManagedUploadCleanup( + "retain-owned-source-after-permanent-failure", + "absent-after-permanent-failure" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + "running", + "failed", + "running", + "failed", + "running", + "failed", + }, + new int[] { + 0, + 0, + } + ), + new GeneratedTusManagedUploadInput( + "hello retries!", + 7, + "managed-retry-exhausted-fingerprint", + "managed-retry-exhausted", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed-retry-exhausted.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "failed", + new GeneratedTusManagedUploadFailure( + "during-protocol-request", + "retryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 500, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[0] + ), + } + ), + new GeneratedTusManagedUploadAttempt( + 1, + "failed", + new GeneratedTusManagedUploadFailure( + "during-protocol-request", + "retryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 500, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[0] + ), + } + ), + new GeneratedTusManagedUploadAttempt( + 2, + "failed", + new GeneratedTusManagedUploadFailure( + "during-protocol-request", + "retryable-protocol-error", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + new GeneratedTusManagedUploadRequest( + "POST", + "endpoint", + 0, + 500, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + }, + new GeneratedTusManagedUploadHeader[0] + ), + } + ), + } + ), }; private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = new GeneratedTusMethodOverride[] { From 979992773168fd616925f5b0ad264327f47806f7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 13:53:29 +0200 Subject: [PATCH 31/63] Add generated managed source unavailable proof --- .../client/GeneratedTusProtocolContract.java | 32 +++- .../TestGeneratedTusManagedUploadRuntime.java | 176 ++++++++++++++++-- 2 files changed, 191 insertions(+), 17 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 2117c95..aeecb32 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1121,7 +1121,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify missing sources and unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"absent-after-source-unavailable\",\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadSourceUnavailable\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"source-unavailable\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"source-unavailable\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadSourceUnavailable\",\n \"summary\": \"Classify source disappearance before protocol requests as terminal without issuing a TUS request.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { @@ -1150,6 +1150,7 @@ final class GeneratedTusProtocolContract { "managedUploadDurableRetry", "managedUploadPermanentFailure", "managedUploadRetryPolicyExhausted", + "managedUploadSourceUnavailable", "managedUploadNetworkConstraint", }; @@ -1246,6 +1247,35 @@ final class GeneratedTusProtocolContract { "react-native", } ), + new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( + "managedUpload", + "feature-over-protocol", + "managedUploadSourceUnavailable", + new String[] { + "java", + "android", + }, + new String[] { + "accept-upload-submission", + "make-source-durable", + "schedule-upload-work", + "classify-failure", + "publish-upload-state", + "cleanup-managed-upload", + }, + new String[] { + "singleUploadLifecycle", + "retryOffsetRecovery", + }, + new String[] { + "android", + "ios", + "browser", + "java", + "node", + "react-native", + } + ), new GeneratedTusProtocolContract.GeneratedTusManagedUploadProofCase( "managedUpload", "feature-over-protocol", diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 0f65087..5df78d9 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -60,6 +60,7 @@ public class TestGeneratedTusManagedUploadRuntime { "android", "durable-os-scheduler", "copy-to-owned-storage", + "available", "platform-key-value-store" ), new GeneratedTusManagedUploadTransport( @@ -195,6 +196,7 @@ public class TestGeneratedTusManagedUploadRuntime { "android", "durable-os-scheduler", "copy-to-owned-storage", + "available", "platform-key-value-store" ), new GeneratedTusManagedUploadTransport( @@ -261,6 +263,7 @@ public class TestGeneratedTusManagedUploadRuntime { "android", "durable-os-scheduler", "copy-to-owned-storage", + "available", "platform-key-value-store" ), new GeneratedTusManagedUploadTransport( @@ -376,6 +379,61 @@ public class TestGeneratedTusManagedUploadRuntime { ), } ), + new GeneratedTusManagedUploadRuntimeCase( + new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadSourceUnavailable", + "android", + "durable-os-scheduler", + "copy-to-owned-storage", + "missing-before-durable-copy", + "platform-key-value-store" + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadTerminal( + "failed", + "source-unavailable" + ), + new GeneratedTusManagedUploadCleanup( + "absent-after-source-unavailable", + "absent-after-permanent-failure" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + "running", + "failed", + }, + new int[0] + ), + new GeneratedTusManagedUploadInput( + "hello missing!", + 7, + "managed-source-unavailable-fingerprint", + "managed-source-unavailable", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed-source-unavailable.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + new GeneratedTusManagedUploadAttempt( + 0, + "failed", + new GeneratedTusManagedUploadFailure( + "before-protocol-request", + "source-unavailable", + -1 + ), + new GeneratedTusManagedUploadRequest[] { + + } + ), + } + ), }; private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = new GeneratedTusMethodOverride[] { @@ -407,7 +465,6 @@ public void shouldRunManagedUploadWithAndroidPlatformState() throws Exception { List states = new ArrayList(); File source = writeSourceFile(testCase); File ownedSource = ownedSourceFile(testCase, source); - copyDurableSource(testCase, source, ownedSource); recordState(testCase, states, stateStore, "pending"); final TusPreferencesURLStore urlStore = @@ -417,20 +474,28 @@ public void shouldRunManagedUploadWithAndroidPlatformState() throws Exception { client.enableResuming(urlStore); client.enableRemoveFingerprintOnSuccess(); - TusExecutor executor = - managedExecutorFor(testCase, client, ownedSource, states, stateStore); - GeneratedTusAndroidScheduler scheduler = - new GeneratedTusAndroidScheduler(testCase, stateStore); try { - Future future = scheduler.submit(new Callable() { - @Override - public Boolean call() throws Exception { - return executor.makeAttempts(); - } - }); - assertTerminalResult(testCase, future); - } finally { - scheduler.shutdown(); + prepareSourceBeforeProtocol(testCase, source, ownedSource, states, stateStore); + TusExecutor executor = + managedExecutorFor(testCase, client, ownedSource, states, stateStore); + GeneratedTusAndroidScheduler scheduler = + new GeneratedTusAndroidScheduler(testCase, stateStore); + try { + Future future = scheduler.submit(new Callable() { + @Override + public Boolean call() throws Exception { + return executor.makeAttempts(); + } + }); + assertTerminalResult(testCase, future); + } finally { + scheduler.shutdown(); + } + } catch (IOException error) { + if (!isSourceUnavailableBeforeProtocol(testCase)) { + throw error; + } + assertTerminalFailure(testCase, error); } cleanupAfterTerminalState(testCase, ownedSource); @@ -445,8 +510,8 @@ public Boolean call() throws Exception { storedStates(stateStore)); assertResumeUrlState(testCase, urlStore); assertOwnedSourceState(testCase, ownedSource); - assertTrue(testCase.scenarioId, source.exists()); - source.delete(); + assertInputSourceState(testCase, source); + assertProtocolRequestCount(testCase, server.requestCount()); } finally { server.stop(); } @@ -588,6 +653,42 @@ private void copyDurableSource( assertTrue(testCase.scenarioId, ownedSource.exists()); } + private void prepareSourceBeforeProtocol( + GeneratedTusManagedUploadRuntimeCase testCase, + File source, + File ownedSource, + List states, + SharedPreferences stateStore) throws IOException { + if ("available".equals(testCase.sourceAvailability)) { + copyDurableSource(testCase, source, ownedSource); + return; + } + if ("missing-before-durable-copy".equals(testCase.sourceAvailability)) { + GeneratedTusManagedUploadAttempt attempt = testCase.attempts[0]; + if (source.exists() && !source.delete()) { + throw new IOException("Could not remove generated input source " + source); + } + recordState(testCase, states, stateStore, "running"); + try { + copyDurableSource(testCase, source, ownedSource); + } catch (IOException error) { + recordState(testCase, states, stateStore, attempt.stateAfterAttempt); + throw error; + } + throw new AssertionError(testCase.scenarioId + " unexpectedly prepared missing source"); + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated source availability " + + testCase.sourceAvailability); + } + + private boolean isSourceUnavailableBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { + return "source-unavailable".equals(testCase.terminalFailure) + && "missing-before-durable-copy".equals(testCase.sourceAvailability); + } + private void cleanupAfterTerminalState( GeneratedTusManagedUploadRuntimeCase testCase, File ownedSource) throws IOException { @@ -612,6 +713,10 @@ private void assertOwnedSourceState( ownedSource.delete(); return; } + if ("absent-after-source-unavailable".equals(testCase.ownedSourceCleanup)) { + assertFalse(testCase.scenarioId, ownedSource.exists()); + return; + } throw new AssertionError( testCase.scenarioId @@ -619,6 +724,18 @@ private void assertOwnedSourceState( + testCase.ownedSourceCleanup); } + private void assertInputSourceState( + GeneratedTusManagedUploadRuntimeCase testCase, + File source) { + if ("missing-before-durable-copy".equals(testCase.sourceAvailability)) { + assertFalse(testCase.scenarioId, source.exists()); + return; + } + + assertTrue(testCase.scenarioId, source.exists()); + source.delete(); + } + private void assertResumeUrlState( GeneratedTusManagedUploadRuntimeCase testCase, TusPreferencesURLStore urlStore) { @@ -635,6 +752,22 @@ private void assertResumeUrlState( + testCase.resumeUrlCleanup); } + private void assertProtocolRequestCount( + GeneratedTusManagedUploadRuntimeCase testCase, + int actualRequestCount) { + assertTrue( + testCase.scenarioId, + actualRequestCount == expectedProtocolRequestCount(testCase)); + } + + private int expectedProtocolRequestCount(GeneratedTusManagedUploadRuntimeCase testCase) { + int count = 0; + for (GeneratedTusManagedUploadAttempt attempt : testCase.attempts) { + count += attempt.requests.length; + } + return count; + } + private void recordState( GeneratedTusManagedUploadRuntimeCase testCase, List states, @@ -745,6 +878,7 @@ void shutdown() { private static final class GeneratedTusManagedUploadServer { private final ServerSocket serverSocket; private final GeneratedTusManagedUploadRuntimeCase testCase; + private volatile int requestCount; private volatile boolean running; private Thread thread; @@ -781,6 +915,10 @@ URL uploadUrlFor(GeneratedTusManagedUploadRuntimeCase testCase) throws IOExcepti return new URL(endpointUrlFor(testCase).toString() + "/" + testCase.input.uploadPath); } + int requestCount() { + return requestCount; + } + private void serve() { while (running) { try { @@ -800,6 +938,7 @@ private void handle(Socket socket) throws IOException { try { GeneratedTusHttpRequest httpRequest = readHttpRequest(socket.getInputStream(), socket.getOutputStream()); + requestCount += 1; GeneratedTusManagedUploadRequest request = findRequest(httpRequest); if (request == null) { respondNotFound(socket.getOutputStream()); @@ -1071,6 +1210,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final String runtime; final String scheduler; final String sourceDurability; + final String sourceAvailability; final String stateBackend; final String locationHeaderName; final String terminalState; @@ -1095,6 +1235,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.runtime = profile.runtime; this.scheduler = profile.scheduler; this.sourceDurability = profile.sourceDurability; + this.sourceAvailability = profile.sourceAvailability; this.stateBackend = profile.stateBackend; this.locationHeaderName = transport.locationHeaderName; this.terminalState = terminal.state; @@ -1124,6 +1265,7 @@ private static final class GeneratedTusManagedUploadRuntimeProfile { final String runtime; final String scheduler; final String sourceDurability; + final String sourceAvailability; final String stateBackend; GeneratedTusManagedUploadRuntimeProfile( @@ -1131,11 +1273,13 @@ private static final class GeneratedTusManagedUploadRuntimeProfile { String runtime, String scheduler, String sourceDurability, + String sourceAvailability, String stateBackend) { this.scenarioId = scenarioId; this.runtime = runtime; this.scheduler = scheduler; this.sourceDurability = sourceDurability; + this.sourceAvailability = sourceAvailability; this.stateBackend = stateBackend; } } From ddd29e0d684e49f04e83a16964f03d4da3793f82 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 14:22:41 +0200 Subject: [PATCH 32/63] Add generated managed network deferral proof --- .../client/GeneratedTusProtocolContract.java | 7 +- .../TestGeneratedTusManagedUploadRuntime.java | 275 +++++++++++++++--- 2 files changed, 244 insertions(+), 38 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index aeecb32..fd7e3a2 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1121,7 +1121,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"absent-after-source-unavailable\",\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadSourceUnavailable\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"needs-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"terminal\": {\n \"state\": \"succeeded\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"unretryable-protocol-error\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"retry-policy-exhausted\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"source-unavailable\",\n \"state\": \"failed\"\n },\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"terminal\": {\n \"failure\": \"source-unavailable\",\n \"state\": \"failed\"\n },\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadSourceUnavailable\",\n \"summary\": \"Classify source disappearance before protocol requests as terminal without issuing a TUS request.\"\n },\n {\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"absent-after-source-unavailable\",\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-while-deferred\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadSourceUnavailable\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"covered-by-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadSourceUnavailable\",\n \"summary\": \"Classify source disappearance before protocol requests as terminal without issuing a TUS request.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-while-deferred\",\n \"resumeUrl\": \"absent-while-deferred\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello later!\",\n \"fingerprint\": \"managed-network-constraint-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-network-constraint.txt\"\n },\n \"uploadPath\": \"managed-network-constraint\"\n },\n \"network\": {\n \"current\": \"metered-network\",\n \"decision\": \"defer-until-network-constraint-satisfied\",\n \"required\": \"unmetered-network\"\n },\n \"outcome\": {\n \"kind\": \"deferred\",\n \"reason\": \"network-constraint-unsatisfied\",\n \"state\": \"pending\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { @@ -1280,9 +1280,12 @@ final class GeneratedTusProtocolContract { "managedUpload", "feature-over-protocol", "managedUploadNetworkConstraint", - new String[0], + new String[] { + "android", + }, new String[] { "accept-upload-submission", + "make-source-durable", "schedule-upload-work", "publish-upload-state", }, diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 5df78d9..3c8911d 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -61,13 +61,20 @@ public class TestGeneratedTusManagedUploadRuntime { "durable-os-scheduler", "copy-to-owned-storage", "available", - "platform-key-value-store" + "platform-key-value-store", + new GeneratedTusManagedUploadNetwork( + "any-network", + "unmetered-network", + "start-upload-work" + ) ), new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadTerminal( + new GeneratedTusManagedUploadOutcome( + "terminal", "succeeded", + "", "" ), new GeneratedTusManagedUploadCleanup( @@ -197,14 +204,21 @@ public class TestGeneratedTusManagedUploadRuntime { "durable-os-scheduler", "copy-to-owned-storage", "available", - "platform-key-value-store" + "platform-key-value-store", + new GeneratedTusManagedUploadNetwork( + "any-network", + "unmetered-network", + "start-upload-work" + ) ), new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadTerminal( + new GeneratedTusManagedUploadOutcome( + "terminal", "failed", - "unretryable-protocol-error" + "unretryable-protocol-error", + "" ), new GeneratedTusManagedUploadCleanup( "retain-owned-source-after-permanent-failure", @@ -264,14 +278,21 @@ public class TestGeneratedTusManagedUploadRuntime { "durable-os-scheduler", "copy-to-owned-storage", "available", - "platform-key-value-store" + "platform-key-value-store", + new GeneratedTusManagedUploadNetwork( + "any-network", + "unmetered-network", + "start-upload-work" + ) ), new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadTerminal( + new GeneratedTusManagedUploadOutcome( + "terminal", "failed", - "retry-policy-exhausted" + "retry-policy-exhausted", + "" ), new GeneratedTusManagedUploadCleanup( "retain-owned-source-after-permanent-failure", @@ -386,14 +407,21 @@ public class TestGeneratedTusManagedUploadRuntime { "durable-os-scheduler", "copy-to-owned-storage", "missing-before-durable-copy", - "platform-key-value-store" + "platform-key-value-store", + new GeneratedTusManagedUploadNetwork( + "any-network", + "unmetered-network", + "start-upload-work" + ) ), new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadTerminal( + new GeneratedTusManagedUploadOutcome( + "terminal", "failed", - "source-unavailable" + "source-unavailable", + "" ), new GeneratedTusManagedUploadCleanup( "absent-after-source-unavailable", @@ -434,6 +462,55 @@ public class TestGeneratedTusManagedUploadRuntime { ), } ), + new GeneratedTusManagedUploadRuntimeCase( + new GeneratedTusManagedUploadRuntimeProfile( + "managedUploadNetworkConstraint", + "android", + "durable-os-scheduler", + "copy-to-owned-storage", + "available", + "platform-key-value-store", + new GeneratedTusManagedUploadNetwork( + "unmetered-network", + "metered-network", + "defer-until-network-constraint-satisfied" + ) + ), + new GeneratedTusManagedUploadTransport( + "Location" + ), + new GeneratedTusManagedUploadOutcome( + "deferred", + "pending", + "", + "network-constraint-unsatisfied" + ), + new GeneratedTusManagedUploadCleanup( + "retain-owned-source-while-deferred", + "absent-while-deferred" + ), + new GeneratedTusManagedUploadRetryPlan( + new String[] { + "pending", + }, + new int[0] + ), + new GeneratedTusManagedUploadInput( + "hello later!", + 7, + "managed-network-constraint-fingerprint", + "managed-network-constraint", + new GeneratedTusManagedUploadMetadata[] { + new GeneratedTusManagedUploadMetadata( + "filename", + "managed-network-constraint.txt" + ), + } + ), + new GeneratedTusManagedUploadAttempt[] { + + } + ), }; private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = new GeneratedTusMethodOverride[] { @@ -476,18 +553,28 @@ public void shouldRunManagedUploadWithAndroidPlatformState() throws Exception { try { prepareSourceBeforeProtocol(testCase, source, ownedSource, states, stateStore); - TusExecutor executor = - managedExecutorFor(testCase, client, ownedSource, states, stateStore); GeneratedTusAndroidScheduler scheduler = new GeneratedTusAndroidScheduler(testCase, stateStore); try { - Future future = scheduler.submit(new Callable() { - @Override - public Boolean call() throws Exception { - return executor.makeAttempts(); - } - }); - assertTerminalResult(testCase, future); + if (shouldDeferBeforeProtocol(testCase)) { + scheduler.deferUntilNetworkConstraintSatisfied(); + assertDeferredResult(testCase); + } else { + TusExecutor executor = + managedExecutorFor( + testCase, + client, + ownedSource, + states, + stateStore); + Future future = scheduler.submit(new Callable() { + @Override + public Boolean call() throws Exception { + return executor.makeAttempts(); + } + }); + assertTerminalResult(testCase, future); + } } finally { scheduler.shutdown(); } @@ -521,14 +608,18 @@ public Boolean call() throws Exception { private void assertTerminalResult( GeneratedTusManagedUploadRuntimeCase testCase, Future future) throws Exception { + if (!"terminal".equals(testCase.outcomeKind)) { + throw new AssertionError(testCase.scenarioId + " expected deferred outcome"); + } + try { boolean result = future.get(); - if (!"succeeded".equals(testCase.terminalState)) { + if (!"succeeded".equals(testCase.outcomeState)) { throw new AssertionError(testCase.scenarioId + " expected terminal failure"); } assertTrue(testCase.scenarioId, result); } catch (ExecutionException error) { - if (!"failed".equals(testCase.terminalState)) { + if (!"failed".equals(testCase.outcomeState)) { throw error; } assertTerminalFailure(testCase, error.getCause()); @@ -538,15 +629,15 @@ private void assertTerminalResult( private void assertTerminalFailure( GeneratedTusManagedUploadRuntimeCase testCase, Throwable error) { - if ("unretryable-protocol-error".equals(testCase.terminalFailure)) { + if ("unretryable-protocol-error".equals(testCase.outcomeFailure)) { assertTrue(testCase.scenarioId, error instanceof ProtocolException); return; } - if ("source-unavailable".equals(testCase.terminalFailure)) { + if ("source-unavailable".equals(testCase.outcomeFailure)) { assertTrue(testCase.scenarioId, error instanceof IOException); return; } - if ("retry-policy-exhausted".equals(testCase.terminalFailure)) { + if ("retry-policy-exhausted".equals(testCase.outcomeFailure)) { assertTrue( testCase.scenarioId, error instanceof ProtocolException || error instanceof IOException); @@ -556,7 +647,36 @@ private void assertTerminalFailure( throw new AssertionError( testCase.scenarioId + " uses unsupported generated terminal failure " - + testCase.terminalFailure); + + testCase.outcomeFailure); + } + + private void assertDeferredResult(GeneratedTusManagedUploadRuntimeCase testCase) { + if ( + !"deferred".equals(testCase.outcomeKind) + || !"pending".equals(testCase.outcomeState) + || !"network-constraint-unsatisfied".equals(testCase.outcomeReason) + || !"defer-until-network-constraint-satisfied".equals(testCase.networkDecision) + || networkConstraintSatisfied(testCase)) { + throw new AssertionError(testCase.scenarioId + " expected deferred network outcome"); + } + } + + private boolean networkConstraintSatisfied(GeneratedTusManagedUploadRuntimeCase testCase) { + if ("offline".equals(testCase.currentNetwork)) { + return false; + } + if ("any-network".equals(testCase.networkRequired)) { + return "metered-network".equals(testCase.currentNetwork) + || "unmetered-network".equals(testCase.currentNetwork); + } + if ("unmetered-network".equals(testCase.networkRequired)) { + return "unmetered-network".equals(testCase.currentNetwork); + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated network requirement " + + testCase.networkRequired); } private TusExecutor managedExecutorFor( @@ -685,10 +805,14 @@ private void prepareSourceBeforeProtocol( } private boolean isSourceUnavailableBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { - return "source-unavailable".equals(testCase.terminalFailure) + return "source-unavailable".equals(testCase.outcomeFailure) && "missing-before-durable-copy".equals(testCase.sourceAvailability); } + private boolean shouldDeferBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { + return "defer-until-network-constraint-satisfied".equals(testCase.networkDecision); + } + private void cleanupAfterTerminalState( GeneratedTusManagedUploadRuntimeCase testCase, File ownedSource) throws IOException { @@ -713,6 +837,11 @@ private void assertOwnedSourceState( ownedSource.delete(); return; } + if ("retain-owned-source-while-deferred".equals(testCase.ownedSourceCleanup)) { + assertTrue(testCase.scenarioId, ownedSource.exists()); + ownedSource.delete(); + return; + } if ("absent-after-source-unavailable".equals(testCase.ownedSourceCleanup)) { assertFalse(testCase.scenarioId, ownedSource.exists()); return; @@ -741,7 +870,8 @@ private void assertResumeUrlState( TusPreferencesURLStore urlStore) { if ( "remove-after-success".equals(testCase.resumeUrlCleanup) - || "absent-after-permanent-failure".equals(testCase.resumeUrlCleanup)) { + || "absent-after-permanent-failure".equals(testCase.resumeUrlCleanup) + || "absent-while-deferred".equals(testCase.resumeUrlCleanup)) { assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); return; } @@ -870,6 +1000,46 @@ Future submit(Callable work) { return worker.submit(work); } + void deferUntilNetworkConstraintSatisfied() { + if (!"durable-os-scheduler".equals(testCase.scheduler)) { + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated scheduler " + + testCase.scheduler); + } + if ( + !"defer-until-network-constraint-satisfied".equals(testCase.networkDecision) + || networkConstraintSatisfied(testCase)) { + throw new AssertionError(testCase.scenarioId + " expected unsatisfied network"); + } + + assertTrue( + testCase.scenarioId, + stateStore.edit() + .putString("scheduler", testCase.scheduler) + .putString("network-required", testCase.networkRequired) + .putString("network-current", testCase.currentNetwork) + .commit()); + } + + private boolean networkConstraintSatisfied(GeneratedTusManagedUploadRuntimeCase testCase) { + if ("offline".equals(testCase.currentNetwork)) { + return false; + } + if ("any-network".equals(testCase.networkRequired)) { + return "metered-network".equals(testCase.currentNetwork) + || "unmetered-network".equals(testCase.currentNetwork); + } + if ("unmetered-network".equals(testCase.networkRequired)) { + return "unmetered-network".equals(testCase.currentNetwork); + } + + throw new AssertionError( + testCase.scenarioId + + " uses unsupported generated network requirement " + + testCase.networkRequired); + } + void shutdown() { worker.shutdownNow(); } @@ -1212,9 +1382,14 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final String sourceDurability; final String sourceAvailability; final String stateBackend; + final String networkRequired; + final String currentNetwork; + final String networkDecision; final String locationHeaderName; - final String terminalState; - final String terminalFailure; + final String outcomeKind; + final String outcomeState; + final String outcomeFailure; + final String outcomeReason; final String ownedSourceCleanup; final String resumeUrlCleanup; final String[] expectedStates; @@ -1226,7 +1401,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { GeneratedTusManagedUploadRuntimeCase( GeneratedTusManagedUploadRuntimeProfile profile, GeneratedTusManagedUploadTransport transport, - GeneratedTusManagedUploadTerminal terminal, + GeneratedTusManagedUploadOutcome outcome, GeneratedTusManagedUploadCleanup cleanup, GeneratedTusManagedUploadRetryPlan retryPlan, GeneratedTusManagedUploadInput input, @@ -1237,9 +1412,14 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.sourceDurability = profile.sourceDurability; this.sourceAvailability = profile.sourceAvailability; this.stateBackend = profile.stateBackend; + this.networkRequired = profile.networkRequired; + this.currentNetwork = profile.currentNetwork; + this.networkDecision = profile.networkDecision; this.locationHeaderName = transport.locationHeaderName; - this.terminalState = terminal.state; - this.terminalFailure = terminal.failure; + this.outcomeKind = outcome.kind; + this.outcomeState = outcome.state; + this.outcomeFailure = outcome.failure; + this.outcomeReason = outcome.reason; this.ownedSourceCleanup = cleanup.ownedSource; this.resumeUrlCleanup = cleanup.resumeUrl; this.expectedStates = retryPlan.expectedStates; @@ -1250,13 +1430,17 @@ private static final class GeneratedTusManagedUploadRuntimeCase { } } - private static final class GeneratedTusManagedUploadTerminal { + private static final class GeneratedTusManagedUploadOutcome { + final String kind; final String state; final String failure; + final String reason; - GeneratedTusManagedUploadTerminal(String state, String failure) { + GeneratedTusManagedUploadOutcome(String kind, String state, String failure, String reason) { + this.kind = kind; this.state = state; this.failure = failure; + this.reason = reason; } } @@ -1267,6 +1451,9 @@ private static final class GeneratedTusManagedUploadRuntimeProfile { final String sourceDurability; final String sourceAvailability; final String stateBackend; + final String networkRequired; + final String currentNetwork; + final String networkDecision; GeneratedTusManagedUploadRuntimeProfile( String scenarioId, @@ -1274,13 +1461,29 @@ private static final class GeneratedTusManagedUploadRuntimeProfile { String scheduler, String sourceDurability, String sourceAvailability, - String stateBackend) { + String stateBackend, + GeneratedTusManagedUploadNetwork network) { this.scenarioId = scenarioId; this.runtime = runtime; this.scheduler = scheduler; this.sourceDurability = sourceDurability; this.sourceAvailability = sourceAvailability; this.stateBackend = stateBackend; + this.networkRequired = network.required; + this.currentNetwork = network.current; + this.networkDecision = network.decision; + } + } + + private static final class GeneratedTusManagedUploadNetwork { + final String required; + final String current; + final String decision; + + GeneratedTusManagedUploadNetwork(String required, String current, String decision) { + this.required = required; + this.current = current; + this.decision = decision; } } From bd548a8aec4e8bfd68fd103b1649f6cef5810262 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 23:35:20 +0200 Subject: [PATCH 33/63] Add Android devdock TUS example proof --- .../Api2DevdockTusUploadExampleTest.java | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java new file mode 100644 index 0000000..ca1840e --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java @@ -0,0 +1,299 @@ +package io.tus.android.client; + +import android.app.Activity; +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowContentResolver; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUploader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@RunWith(RobolectricTestRunner.class) +public class Api2DevdockTusUploadExampleTest { + private static final String PROVIDER_AUTHORITY = "io.tus.android.client.api2devdock"; + + @Test + public void uploadsAndroidContentUriToTransloaditAssembly() throws Exception { + System.setProperty("http.strictPostRedirect", "true"); + + final String scenarioPath = scenarioPath(); + if (!isRequired()) { + Assume.assumeTrue( + "API2 devdock scenario is only required through the dedicated API2 QA task", + scenarioPath != null + ); + } + if (scenarioPath == null) { + throw new IllegalStateException("API2_SDK_EXAMPLE_SCENARIO must be set"); + } + + final JSONObject scenario = loadScenario(scenarioPath); + final JSONObject createResponse = + scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final Activity activity = Robolectric.setupActivity(Activity.class); + final String uploadUrl = uploadWithTus(activity, scenario, createResponse); + writeResult(uploadUrl); + + assertNotNull(uploadUrl); + } + + private static String uploadWithTus( + Activity activity, + JSONObject scenario, + JSONObject createResponse + ) throws IOException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = scenarioBytes(uploadConfig); + final Uri uri = registerContentUri(activity, content); + + final SharedPreferences preferences = activity.getSharedPreferences( + "api2-devdock-tus-upload", + 0 + ); + preferences.edit().clear().commit(); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(new URL(scalarString( + resolveValue(uploadConfig.getJSONObject("tusUrl"), scenario, createResponse) + ))); + client.enableResuming(new TusPreferencesURLStore(preferences)); + client.enableRemoveFingerprintOnSuccess(); + + final TusAndroidUpload upload = new TusAndroidUpload(uri, activity); + upload.setFingerprint(scenario.getString("scenarioId") + "-android-devdock-example"); + upload.setMetadata(uploadMetadata(uploadConfig, scenario, createResponse)); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + + assertEquals(content.length, uploader.getOffset()); + assertNotNull(uploader.getUploadURL()); + + return uploader.getUploadURL().toString(); + } + + private static Uri registerContentUri(Activity activity, byte[] content) throws IOException { + final File source = new File(activity.getCacheDir(), "api2-devdock-upload.txt"); + Files.write(source.toPath(), content); + + final Uri uri = Uri.parse("content://" + PROVIDER_AUTHORITY + "/api2-devdock-upload.txt"); + ShadowContentResolver.registerProviderInternal( + PROVIDER_AUTHORITY, + new Api2DevdockContentProvider(source) + ); + + return uri; + } + + private static JSONObject loadScenario(String scenarioPath) throws IOException { + final byte[] contents = Files.readAllBytes(Paths.get(scenarioPath)); + return new JSONObject(new String(contents, StandardCharsets.UTF_8)); + } + + private static String scenarioPath() { + final String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO"); + if (scenarioPath != null && !scenarioPath.isEmpty()) { + return scenarioPath; + } + + final String defaultPath = "tus-android-client/api2-scenario.json"; + if (Files.exists(Paths.get(defaultPath))) { + return defaultPath; + } + + return null; + } + + private static boolean isRequired() { + return "true".equals(System.getProperty("api2DevdockTusUpload.required")); + } + + private static void writeResult(String uploadUrl) throws IOException { + final String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); + if (resultPath == null || resultPath.isEmpty()) { + return; + } + + final JSONObject result = new JSONObject(); + result.put("uploadUrl", uploadUrl); + Files.write( + Paths.get(resultPath), + (result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8) + ); + } + + private static byte[] scenarioBytes(JSONObject uploadConfig) { + final JSONObject source = uploadConfig.getJSONObject("source"); + final String kind = source.getString("kind"); + if (!"bytes".equals(kind)) { + throw new IllegalArgumentException("unsupported source kind " + kind); + } + + final String encoding = source.getString("encoding"); + if (!"utf8".equals(encoding)) { + throw new IllegalArgumentException("unsupported source encoding " + encoding); + } + + return source.getString("value").getBytes(StandardCharsets.UTF_8); + } + + private static Map uploadMetadata( + JSONObject uploadConfig, + JSONObject scenario, + JSONObject createResponse + ) { + final JSONArray fields = uploadConfig.getJSONArray("metadata"); + final Map metadata = new LinkedHashMap(); + for (int index = 0; index < fields.length(); index++) { + final JSONObject field = fields.getJSONObject(index); + metadata.put( + field.getString("name"), + scalarString(resolveValue( + field.getJSONObject("value"), + scenario, + createResponse + )) + ); + } + + return metadata; + } + + private static Object resolveValue( + JSONObject valueSpec, + JSONObject scenario, + JSONObject createResponse + ) { + if (valueSpec.has("value")) { + return valueSpec.get("value"); + } + + final JSONObject source = valueSpec.getJSONObject("source"); + final String root = source.getString("root"); + final Object rootValue; + if ("scenario".equals(root)) { + rootValue = scenario; + } else if ("createResponse".equals(root)) { + rootValue = createResponse; + } else { + throw new IllegalArgumentException("unsupported scenario value root " + root); + } + + return readPath(rootValue, source.getJSONArray("path")); + } + + private static Object readPath(Object value, JSONArray pathParts) { + Object current = value; + for (int index = 0; index < pathParts.length(); index++) { + final Object part = pathParts.get(index); + if (current instanceof JSONObject && part instanceof String) { + current = ((JSONObject) current).get((String) part); + continue; + } + + if (current instanceof JSONArray && part instanceof Number) { + current = ((JSONArray) current).get(((Number) part).intValue()); + continue; + } + + throw new IllegalArgumentException("cannot read scenario path part " + part); + } + + return current; + } + + private static String scalarString(Object value) { + if (JSONObject.NULL.equals(value)) { + return "null"; + } + + return String.valueOf(value); + } + + private static final class Api2DevdockContentProvider extends ContentProvider { + private final File source; + + Api2DevdockContentProvider(File source) { + this.source = source; + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query( + Uri uri, + String[] projection, + String selection, + String[] selectionArgs, + String sortOrder + ) { + final MatrixCursor cursor = new MatrixCursor( + new String[]{OpenableColumns.SIZE, OpenableColumns.DISPLAY_NAME} + ); + cursor.addRow(new Object[]{source.length(), source.getName()}); + + return cursor; + } + + @Override + public String getType(Uri uri) { + return "text/plain"; + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + return ParcelFileDescriptor.open(source, ParcelFileDescriptor.MODE_READ_ONLY); + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + throw new UnsupportedOperationException(); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } + } +} From 4fe24e23b904b85fa944941dbb7de21a986b5cba Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 1 Jun 2026 23:43:14 +0200 Subject: [PATCH 34/63] Declare Android example JSON errors --- .../client/Api2DevdockTusUploadExampleTest.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java index ca1840e..84b01d0 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java @@ -11,6 +11,7 @@ import android.provider.OpenableColumns; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; import org.junit.Assume; import org.junit.Test; @@ -69,7 +70,7 @@ private static String uploadWithTus( Activity activity, JSONObject scenario, JSONObject createResponse - ) throws IOException, ProtocolException { + ) throws IOException, JSONException, ProtocolException { final JSONObject uploadConfig = scenario.getJSONObject("upload"); final byte[] content = scenarioBytes(uploadConfig); final Uri uri = registerContentUri(activity, content); @@ -118,7 +119,7 @@ private static Uri registerContentUri(Activity activity, byte[] content) throws return uri; } - private static JSONObject loadScenario(String scenarioPath) throws IOException { + private static JSONObject loadScenario(String scenarioPath) throws IOException, JSONException { final byte[] contents = Files.readAllBytes(Paths.get(scenarioPath)); return new JSONObject(new String(contents, StandardCharsets.UTF_8)); } @@ -141,7 +142,7 @@ private static boolean isRequired() { return "true".equals(System.getProperty("api2DevdockTusUpload.required")); } - private static void writeResult(String uploadUrl) throws IOException { + private static void writeResult(String uploadUrl) throws IOException, JSONException { final String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); if (resultPath == null || resultPath.isEmpty()) { return; @@ -155,7 +156,7 @@ private static void writeResult(String uploadUrl) throws IOException { ); } - private static byte[] scenarioBytes(JSONObject uploadConfig) { + private static byte[] scenarioBytes(JSONObject uploadConfig) throws JSONException { final JSONObject source = uploadConfig.getJSONObject("source"); final String kind = source.getString("kind"); if (!"bytes".equals(kind)) { @@ -174,7 +175,7 @@ private static Map uploadMetadata( JSONObject uploadConfig, JSONObject scenario, JSONObject createResponse - ) { + ) throws JSONException { final JSONArray fields = uploadConfig.getJSONArray("metadata"); final Map metadata = new LinkedHashMap(); for (int index = 0; index < fields.length(); index++) { @@ -196,7 +197,7 @@ private static Object resolveValue( JSONObject valueSpec, JSONObject scenario, JSONObject createResponse - ) { + ) throws JSONException { if (valueSpec.has("value")) { return valueSpec.get("value"); } @@ -215,7 +216,7 @@ private static Object resolveValue( return readPath(rootValue, source.getJSONArray("path")); } - private static Object readPath(Object value, JSONArray pathParts) { + private static Object readPath(Object value, JSONArray pathParts) throws JSONException { Object current = value; for (int index = 0; index < pathParts.length(); index++) { final Object part = pathParts.get(index); From 698f0ed64494966ae697941cbc666b3666b4a00f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 00:51:50 +0200 Subject: [PATCH 35/63] Disable Conscrypt in devdock Android example --- .../io/tus/android/client/Api2DevdockTusUploadExampleTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java index 84b01d0..5a9f55d 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java @@ -18,6 +18,7 @@ import org.junit.runner.RunWith; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.ConscryptMode; import org.robolectric.shadows.ShadowContentResolver; import java.io.File; @@ -38,6 +39,7 @@ import static org.junit.Assert.assertNotNull; @RunWith(RobolectricTestRunner.class) +@ConscryptMode(ConscryptMode.Mode.OFF) public class Api2DevdockTusUploadExampleTest { private static final String PROVIDER_AUTHORITY = "io.tus.android.client.api2devdock"; From 711953dc3af2835afb777700998d0508edee44f1 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Tue, 2 Jun 2026 00:59:28 +0200 Subject: [PATCH 36/63] Attach Android devdock content provider authority --- .../android/client/Api2DevdockTusUploadExampleTest.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java index 5a9f55d..61cf2fa 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java @@ -4,6 +4,7 @@ import android.content.ContentProvider; import android.content.ContentValues; import android.content.SharedPreferences; +import android.content.pm.ProviderInfo; import android.database.Cursor; import android.database.MatrixCursor; import android.net.Uri; @@ -113,9 +114,13 @@ private static Uri registerContentUri(Activity activity, byte[] content) throws Files.write(source.toPath(), content); final Uri uri = Uri.parse("content://" + PROVIDER_AUTHORITY + "/api2-devdock-upload.txt"); + final Api2DevdockContentProvider provider = new Api2DevdockContentProvider(source); + final ProviderInfo providerInfo = new ProviderInfo(); + providerInfo.authority = PROVIDER_AUTHORITY; + provider.attachInfo(activity, providerInfo); ShadowContentResolver.registerProviderInternal( PROVIDER_AUTHORITY, - new Api2DevdockContentProvider(source) + provider ); return uri; From 6836f1be4d31d68b254101a47a5775f0bf198f8f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 15:10:51 +0200 Subject: [PATCH 37/63] Regenerate Android managed upload headers --- .../TestGeneratedTusManagedUploadRuntime.java | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 3c8911d..eda69bd 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -125,12 +125,24 @@ public class TestGeneratedTusManagedUploadRuntime { "Upload-Length", "14" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC50eHQ=" + ), }, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Location", "https://tus.io/uploads/managed-durable-retry" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), } ), new GeneratedTusManagedUploadRequest( @@ -143,12 +155,24 @@ public class TestGeneratedTusManagedUploadRuntime { "Upload-Offset", "0" ), + new GeneratedTusManagedUploadHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), }, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Offset", "7" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), } ), } @@ -163,7 +187,12 @@ public class TestGeneratedTusManagedUploadRuntime { "upload", 0, 200, - new GeneratedTusManagedUploadHeader[0], + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), + }, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Length", @@ -173,6 +202,10 @@ public class TestGeneratedTusManagedUploadRuntime { "Upload-Offset", "7" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), } ), new GeneratedTusManagedUploadRequest( @@ -185,12 +218,24 @@ public class TestGeneratedTusManagedUploadRuntime { "Upload-Offset", "7" ), + new GeneratedTusManagedUploadHeader( + "Content-Type", + "application/offset+octet-stream" + ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), }, new GeneratedTusManagedUploadHeader[] { new GeneratedTusManagedUploadHeader( "Upload-Offset", "14" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), } ), } @@ -264,6 +309,14 @@ public class TestGeneratedTusManagedUploadRuntime { "Upload-Length", "14" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1wZXJtYW5lbnQtZmFpbHVyZS50eHQ=" + ), }, new GeneratedTusManagedUploadHeader[0] ), @@ -345,6 +398,14 @@ public class TestGeneratedTusManagedUploadRuntime { "Upload-Length", "14" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), }, new GeneratedTusManagedUploadHeader[0] ), @@ -369,6 +430,14 @@ public class TestGeneratedTusManagedUploadRuntime { "Upload-Length", "14" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), }, new GeneratedTusManagedUploadHeader[0] ), @@ -393,6 +462,14 @@ public class TestGeneratedTusManagedUploadRuntime { "Upload-Length", "14" ), + new GeneratedTusManagedUploadHeader( + "Tus-Resumable", + "1.0.0" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), }, new GeneratedTusManagedUploadHeader[0] ), From 9fc69970426a3d33961541e28c9f4bf1c717f1c8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Wed, 3 Jun 2026 15:44:43 +0200 Subject: [PATCH 38/63] Regenerate Android default header fixtures --- .../client/GeneratedTusProtocolContract.java | 19 + .../TestGeneratedTusManagedUploadRuntime.java | 352 ++++++++++-------- 2 files changed, 216 insertions(+), 155 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index fd7e3a2..ae999ef 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -6,10 +6,17 @@ package io.tus.android.client; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + /** * Generated TUS protocol contract fixture used by tests. */ final class GeneratedTusProtocolContract { + static final Map DEFAULT_REQUEST_HEADERS = defaultRequestHeaders(); + static final Map DEFAULT_RESPONSE_HEADERS = defaultResponseHeaders(); + static final GeneratedTusWireVersion[] WIRE_VERSIONS = new GeneratedTusWireVersion[] { new GeneratedTusWireVersion( true, @@ -1310,6 +1317,18 @@ final class GeneratedTusProtocolContract { private GeneratedTusProtocolContract() { } + private static Map defaultRequestHeaders() { + Map result = new LinkedHashMap(); + result.put("Tus-Resumable", "1.0.0"); + return Collections.unmodifiableMap(result); + } + + private static Map defaultResponseHeaders() { + Map result = new LinkedHashMap(); + result.put("Tus-Resumable", "1.0.0"); + return Collections.unmodifiableMap(result); + } + /** * Generated wire-version fixture. */ diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index eda69bd..3533088 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -120,60 +120,56 @@ public class TestGeneratedTusManagedUploadRuntime { "endpoint", 0, 201, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Length", - "14" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC50eHQ=" + ), + } ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), - new GeneratedTusManagedUploadHeader( - "Upload-Metadata", - "filename bWFuYWdlZC50eHQ=" - ), - }, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Location", - "https://tus.io/uploads/managed-durable-retry" - ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), - } + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Location", + "https://tus.io/uploads/managed-durable-retry" + ), + } + ) ), new GeneratedTusManagedUploadRequest( "PATCH", "upload", 7, 204, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "0" - ), - new GeneratedTusManagedUploadHeader( - "Content-Type", - "application/offset+octet-stream" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "0" + ), + new GeneratedTusManagedUploadHeader( + "Content-Type", + "application/offset+octet-stream" + ), + } ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), - }, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "7" - ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), - } + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + } + ) ), } ), @@ -187,56 +183,51 @@ public class TestGeneratedTusManagedUploadRuntime { "upload", 0, 200, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), - }, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Length", - "14" - ), - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "7" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[0] ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), - } + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + } + ) ), new GeneratedTusManagedUploadRequest( "PATCH", "upload", 7, 204, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "7" - ), - new GeneratedTusManagedUploadHeader( - "Content-Type", - "application/offset+octet-stream" - ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), - }, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "14" - ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), + new GeneratedTusManagedUploadHeader( + "Content-Type", + "application/offset+octet-stream" + ), + } ), - } + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "14" + ), + } + ) ), } ), @@ -304,21 +295,23 @@ public class TestGeneratedTusManagedUploadRuntime { "endpoint", 0, 400, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Length", - "14" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1wZXJtYW5lbnQtZmFpbHVyZS50eHQ=" + ), + } ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), - new GeneratedTusManagedUploadHeader( - "Upload-Metadata", - "filename bWFuYWdlZC1wZXJtYW5lbnQtZmFpbHVyZS50eHQ=" - ), - }, - new GeneratedTusManagedUploadHeader[0] + new GeneratedTusManagedUploadHeaderSet( + false, + new GeneratedTusManagedUploadHeader[0] + ) ), } ), @@ -393,21 +386,23 @@ public class TestGeneratedTusManagedUploadRuntime { "endpoint", 0, 500, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Length", - "14" - ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), + } ), - new GeneratedTusManagedUploadHeader( - "Upload-Metadata", - "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" - ), - }, - new GeneratedTusManagedUploadHeader[0] + new GeneratedTusManagedUploadHeaderSet( + false, + new GeneratedTusManagedUploadHeader[0] + ) ), } ), @@ -425,21 +420,23 @@ public class TestGeneratedTusManagedUploadRuntime { "endpoint", 0, 500, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Length", - "14" - ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), + } ), - new GeneratedTusManagedUploadHeader( - "Upload-Metadata", - "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" - ), - }, - new GeneratedTusManagedUploadHeader[0] + new GeneratedTusManagedUploadHeaderSet( + false, + new GeneratedTusManagedUploadHeader[0] + ) ), } ), @@ -457,21 +454,23 @@ public class TestGeneratedTusManagedUploadRuntime { "endpoint", 0, 500, - new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Length", - "14" - ), - new GeneratedTusManagedUploadHeader( - "Tus-Resumable", - "1.0.0" - ), - new GeneratedTusManagedUploadHeader( - "Upload-Metadata", - "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + new GeneratedTusManagedUploadHeaderSet( + true, + new GeneratedTusManagedUploadHeader[] { + new GeneratedTusManagedUploadHeader( + "Upload-Length", + "14" + ), + new GeneratedTusManagedUploadHeader( + "Upload-Metadata", + "filename bWFuYWdlZC1yZXRyeS1leGhhdXN0ZWQudHh0" + ), + } ), - }, - new GeneratedTusManagedUploadHeader[0] + new GeneratedTusManagedUploadHeaderSet( + false, + new GeneratedTusManagedUploadHeader[0] + ) ), } ), @@ -1223,8 +1222,29 @@ private boolean matchesRequest( if (!methodMatches(httpRequest, request)) { return false; } - for (GeneratedTusManagedUploadHeader header : request.requestHeaders) { - if (!header.value.equals(headerValue(httpRequest.headers, header.name))) { + if (!headersMatch( + httpRequest.headers, + request.requestHeaders, + GeneratedTusProtocolContract.DEFAULT_REQUEST_HEADERS)) { + return false; + } + + return true; + } + + private boolean headersMatch( + Map> actualHeaders, + GeneratedTusManagedUploadHeaderSet expectedHeaders, + Map defaultHeaders) { + if (expectedHeaders.includesDefaultProtocolHeaders) { + for (Map.Entry entry : defaultHeaders.entrySet()) { + if (!entry.getValue().equals(headerValue(actualHeaders, entry.getKey()))) { + return false; + } + } + } + for (GeneratedTusManagedUploadHeader header : expectedHeaders.headers) { + if (!header.value.equals(headerValue(actualHeaders, header.name))) { return false; } } @@ -1266,11 +1286,14 @@ private void respond(OutputStream output, GeneratedTusManagedUploadRequest reque throws IOException { StringBuilder response = new StringBuilder(); response.append("HTTP/1.1 ").append(request.statusCode).append(" Generated\r\n"); - for (GeneratedTusManagedUploadHeader header : request.responseHeaders) { - response.append(header.name) - .append(": ") - .append(responseHeaderValueFor(header)) - .append("\r\n"); + if (request.responseHeaders.includesDefaultProtocolHeaders) { + for (Map.Entry entry + : GeneratedTusProtocolContract.DEFAULT_RESPONSE_HEADERS.entrySet()) { + appendHeader(response, entry.getKey(), entry.getValue()); + } + } + for (GeneratedTusManagedUploadHeader header : request.responseHeaders.headers) { + appendHeader(response, header.name, responseHeaderValueFor(header)); } response.append("Content-Length: 0\r\n"); response.append("Connection: close\r\n"); @@ -1278,6 +1301,13 @@ private void respond(OutputStream output, GeneratedTusManagedUploadRequest reque output.write(response.toString().getBytes(StandardCharsets.UTF_8)); } + private static void appendHeader(StringBuilder response, String name, String value) { + response.append(name) + .append(": ") + .append(value) + .append("\r\n"); + } + private GeneratedTusHttpRequest readHttpRequest(InputStream input, OutputStream output) throws IOException { ByteArrayOutputStream headerBytes = new ByteArrayOutputStream(); @@ -1648,16 +1678,16 @@ private static final class GeneratedTusManagedUploadRequest { final String url; final int bodySize; final int statusCode; - final GeneratedTusManagedUploadHeader[] requestHeaders; - final GeneratedTusManagedUploadHeader[] responseHeaders; + final GeneratedTusManagedUploadHeaderSet requestHeaders; + final GeneratedTusManagedUploadHeaderSet responseHeaders; GeneratedTusManagedUploadRequest( String method, String url, int bodySize, int statusCode, - GeneratedTusManagedUploadHeader[] requestHeaders, - GeneratedTusManagedUploadHeader[] responseHeaders) { + GeneratedTusManagedUploadHeaderSet requestHeaders, + GeneratedTusManagedUploadHeaderSet responseHeaders) { this.method = method; this.url = url; this.bodySize = bodySize; @@ -1667,6 +1697,18 @@ private static final class GeneratedTusManagedUploadRequest { } } + private static final class GeneratedTusManagedUploadHeaderSet { + final boolean includesDefaultProtocolHeaders; + final GeneratedTusManagedUploadHeader[] headers; + + GeneratedTusManagedUploadHeaderSet( + boolean includesDefaultProtocolHeaders, + GeneratedTusManagedUploadHeader[] headers) { + this.includesDefaultProtocolHeaders = includesDefaultProtocolHeaders; + this.headers = headers; + } + } + private static final class GeneratedTusManagedUploadHeader { final String name; final String value; From 31df3ceeddd190062f0147ea550c112bec4d95d6 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 02:09:40 +0200 Subject: [PATCH 39/63] Add generated TUS request ID proof --- ...eneratedTusClientConformanceScenarios.java | 25 +++++++++++ .../client/GeneratedTusProtocolContract.java | 41 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java index d1ec0d5..b3fafcc 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java @@ -482,6 +482,31 @@ final class GeneratedTusClientConformanceScenarios { new String[0] ) ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "request-id-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "requestIdHeaders", + "requestIdHeaders", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "add-request-id-header", + "apply-custom-request-headers", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact", + null, + null + ), + new String[0] + ) + ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "resume-from-previous-upload", new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index ae999ef..04ce22f 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -599,6 +599,47 @@ final class GeneratedTusProtocolContract { "apply-custom-request-headers", } ), + new GeneratedTusClientFeature( + new GeneratedTusClientFeatureConformance( + new String[] { + "requestIdHeaders", + }, + "covered-by-generated-scenario" + ), + "Add generated request IDs after protocol and custom request headers.", + "requestIdHeaders", + new GeneratedTusClientFeatureFlowStep[] { + new GeneratedTusClientFeatureFlowStep( + "primitive", + "", + "add-request-id-header", + "", + "Generate a request ID and apply it after custom request headers so it is authoritative." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "createTusUpload", + "", + "", + "Create uploads with a generated request ID." + ), + new GeneratedTusClientFeatureFlowStep( + "operation", + "patchTusUpload", + "", + "", + "Upload bytes with a generated request ID." + ), + }, + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "add-request-id-header", + "apply-custom-request-headers", + } + ), new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( new String[] { From ac932d5058f7ec1002fe89d7cc29fb490a8f6b49 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:34:44 +0200 Subject: [PATCH 40/63] Regenerate TUS contract proofs --- ...eneratedTusClientConformanceScenarios.java | 76 +++++++++++++++++++ .../client/GeneratedTusProtocolContract.java | 8 +- .../TestGeneratedTusManagedUploadRuntime.java | 16 ++-- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java index b3fafcc..7747a74 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java @@ -147,6 +147,43 @@ final class GeneratedTusClientConformanceScenarios { } ) ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "upload-body-headers", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "protocolVersionSelection", + "ietfDraft05ChunkedUploadComplete", + new String[] { + "getTusUploadOffset", + "patchTusUpload", + }, + new String[] { + "select-client-protocol", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:5:11", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ) + ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "upload-body-headers", new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( @@ -747,6 +784,45 @@ final class GeneratedTusClientConformanceScenarios { } ) ), + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( + "deferred-length-upload", + new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + "success", + null + ), + "deferredLengthUpload", + "deferredLengthChunkedUpload", + new String[] { + "createTusUpload", + "patchTusUpload", + }, + new String[] { + "defer-upload-length", + "emit-chunk-complete", + "emit-progress", + }, + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( + "exact-except-extra-progress", + "milestone", + "may-emit-extra-samples" + ), + new String[] { + "upload-url-available", + "progress:0:11", + "progress:5:11", + "chunk-complete:5:5:11", + "progress:5:11", + "progress:10:11", + "chunk-complete:5:10:11", + "progress:10:11", + "progress:11:11", + "chunk-complete:1:11:11", + "success", + "source-close", + } + ) + ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( "override-patch-method", new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 04ce22f..d97acd2 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -454,10 +454,11 @@ final class GeneratedTusProtocolContract { new GeneratedTusClientFeatureConformance( new String[] { "deferredLengthUpload", + "deferredLengthChunkedUpload", }, "covered-by-generated-scenario" ), - "Create an upload without a known length and declare the length on final PATCH.", + "Create an upload without a known length and declare the length on first PATCH.", "deferredLengthUpload", new GeneratedTusClientFeatureFlowStep[] { new GeneratedTusClientFeatureFlowStep( @@ -472,14 +473,14 @@ final class GeneratedTusProtocolContract { "", "defer-upload-length", "", - "Track the source until the final chunk reveals the total size." + "Track the source so the first PATCH can declare the total size." ), new GeneratedTusClientFeatureFlowStep( "operation", "patchTusUpload", "", "", - "Declare Upload-Length on the final chunk request." + "Declare Upload-Length on the first chunk request." ), }, new String[] { @@ -1062,6 +1063,7 @@ final class GeneratedTusProtocolContract { new GeneratedTusClientFeatureConformance( new String[] { "ietfDraft05CreationWithUpload", + "ietfDraft05ChunkedUploadComplete", "ietfDraft03ResumeWithoutKnownLength", }, "covered-by-generated-scenario" diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 3533088..b7680b7 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -151,14 +151,14 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadHeaderSet( true, new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "0" - ), new GeneratedTusManagedUploadHeader( "Content-Type", "application/offset+octet-stream" ), + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "0" + ), } ), new GeneratedTusManagedUploadHeaderSet( @@ -209,14 +209,14 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadHeaderSet( true, new GeneratedTusManagedUploadHeader[] { - new GeneratedTusManagedUploadHeader( - "Upload-Offset", - "7" - ), new GeneratedTusManagedUploadHeader( "Content-Type", "application/offset+octet-stream" ), + new GeneratedTusManagedUploadHeader( + "Upload-Offset", + "7" + ), } ), new GeneratedTusManagedUploadHeaderSet( From b3f0662f1ff19385c0079e7008dd3e8dd158ed44 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 03:57:59 +0200 Subject: [PATCH 41/63] Regenerate TUS deferred length proofs --- ...eneratedTusClientConformanceScenarios.java | 50 ++++++++++++++++--- .../client/GeneratedTusProtocolContract.java | 10 ++-- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java index 7747a74..2c40bcd 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java @@ -35,6 +35,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -68,6 +69,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -99,6 +101,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -135,6 +138,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -165,6 +169,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -202,6 +207,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -231,6 +237,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -252,6 +259,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -273,6 +281,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -294,6 +303,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -315,6 +325,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -336,6 +347,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -357,6 +369,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -378,6 +391,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -399,6 +413,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -420,6 +435,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -443,6 +459,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -466,6 +483,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -490,6 +508,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -514,6 +533,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -539,6 +559,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -564,6 +585,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -599,6 +621,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -631,6 +654,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -659,6 +683,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -687,6 +712,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -715,6 +741,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -743,6 +770,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -771,6 +799,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + "allow-known-total-before-declaration", "milestone", "may-emit-extra-samples" ), @@ -804,17 +833,18 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + "allow-known-total-before-declaration", "milestone", "may-emit-extra-samples" ), new String[] { "upload-url-available", - "progress:0:11", - "progress:5:11", - "chunk-complete:5:5:11", - "progress:5:11", - "progress:10:11", - "chunk-complete:5:10:11", + "progress:0:null", + "progress:5:null", + "chunk-complete:5:5:null", + "progress:5:null", + "progress:10:null", + "chunk-complete:5:10:null", "progress:10:11", "progress:11:11", "chunk-complete:1:11:11", @@ -842,6 +872,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[0] @@ -869,6 +900,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact-except-extra-progress", + null, "milestone", "may-emit-extra-samples" ), @@ -905,6 +937,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -936,6 +969,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -964,6 +998,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -992,6 +1027,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -1020,6 +1056,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { @@ -1049,6 +1086,7 @@ final class GeneratedTusClientConformanceScenarios { new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( "exact", null, + null, null ), new String[] { diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index d97acd2..928c677 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -458,7 +458,7 @@ final class GeneratedTusProtocolContract { }, "covered-by-generated-scenario" ), - "Create an upload without a known length and declare the length on first PATCH.", + "Create an upload without a known length and declare the length on the final upload request.", "deferredLengthUpload", new GeneratedTusClientFeatureFlowStep[] { new GeneratedTusClientFeatureFlowStep( @@ -473,14 +473,14 @@ final class GeneratedTusProtocolContract { "", "defer-upload-length", "", - "Track the source so the first PATCH can declare the total size." + "Track the source until the final upload request reveals the total size." ), new GeneratedTusClientFeatureFlowStep( "operation", "patchTusUpload", "", "", - "Declare Upload-Length on the first chunk request." + "Declare Upload-Length on the final upload request." ), }, new String[] { @@ -489,6 +489,7 @@ final class GeneratedTusProtocolContract { }, new String[] { "defer-upload-length", + "emit-chunk-complete", "emit-progress", } ), @@ -1622,14 +1623,17 @@ static final class GeneratedTusClientConformanceEvents { */ static final class GeneratedTusClientConformanceEventPolicy { final String matching; + final String deferredLengthBytesTotal; final String progress; final String transportProgress; GeneratedTusClientConformanceEventPolicy( String matching, + String deferredLengthBytesTotal, String progress, String transportProgress) { this.matching = matching; + this.deferredLengthBytesTotal = deferredLengthBytesTotal; this.progress = progress; this.transportProgress = transportProgress; } From 9e3edde652a8319bd309d36bbf98f307b9a69ec6 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 04:21:12 +0200 Subject: [PATCH 42/63] Regenerate TUS event alternatives --- ...eneratedTusClientConformanceScenarios.java | 218 ++++++++++++++++-- .../client/GeneratedTusProtocolContract.java | 7 +- 2 files changed, 208 insertions(+), 17 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java index 2c40bcd..d0e4b21 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java @@ -48,6 +48,16 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:11:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -79,6 +89,13 @@ final class GeneratedTusClientConformanceScenarios { "upload-url-available", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -118,6 +135,20 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:1:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -148,6 +179,13 @@ final class GeneratedTusClientConformanceScenarios { "upload-url-available", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -186,6 +224,20 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:1:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -218,6 +270,14 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:6:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -240,7 +300,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -262,7 +323,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -284,7 +346,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -306,7 +369,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -328,7 +392,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -350,7 +415,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -372,7 +438,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -394,7 +461,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -416,7 +484,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -438,7 +507,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -462,7 +532,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -486,7 +557,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -511,7 +583,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -536,7 +609,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -562,7 +636,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -600,6 +675,18 @@ final class GeneratedTusClientConformanceScenarios { "url-storage-remove:tus::contract-resume-fingerprint::1337", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -632,6 +719,14 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:11:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -661,6 +756,11 @@ final class GeneratedTusClientConformanceScenarios { "source-open:array-buffer:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ) ), @@ -690,6 +790,11 @@ final class GeneratedTusClientConformanceScenarios { "source-open:array-buffer-view:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ) ), @@ -719,6 +824,11 @@ final class GeneratedTusClientConformanceScenarios { "source-open:web-readable-stream:null", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ) ), @@ -748,6 +858,11 @@ final class GeneratedTusClientConformanceScenarios { "source-open:node-readable-stream:null", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ) ), @@ -777,6 +892,11 @@ final class GeneratedTusClientConformanceScenarios { "source-open:node-path-reference:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], } ) ), @@ -810,6 +930,14 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:11:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -850,6 +978,32 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:1:11:11", "success", "source-close", + }, + new String[][] { + new String[0], + new String[] { + "progress:0:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "chunk-complete:5:5:11", + }, + new String[] { + "progress:5:11", + }, + new String[] { + "progress:10:11", + }, + new String[] { + "chunk-complete:5:10:11", + }, + new String[0], + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -875,7 +1029,8 @@ final class GeneratedTusClientConformanceScenarios { null, null ), - new String[0] + new String[0], + new String[0][0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -909,6 +1064,12 @@ final class GeneratedTusClientConformanceScenarios { "chunk-complete:5:5:11", "progress:11:11", "chunk-complete:6:11:11", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -942,6 +1103,9 @@ final class GeneratedTusClientConformanceScenarios { ), new String[] { "request-abort:3", + }, + new String[][] { + new String[0], } ) ), @@ -977,6 +1141,12 @@ final class GeneratedTusClientConformanceScenarios { "retry-schedule:0", "should-retry:0:true", "retry-schedule:0", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -1006,6 +1176,12 @@ final class GeneratedTusClientConformanceScenarios { "after-response:0", "success", "source-close", + }, + new String[][] { + new String[0], + new String[0], + new String[0], + new String[0], } ) ), @@ -1032,6 +1208,9 @@ final class GeneratedTusClientConformanceScenarios { ), new String[] { "request-abort:0", + }, + new String[][] { + new String[0], } ) ), @@ -1061,6 +1240,9 @@ final class GeneratedTusClientConformanceScenarios { ), new String[] { "request-abort:1", + }, + new String[][] { + new String[0], } ) ), @@ -1092,6 +1274,10 @@ final class GeneratedTusClientConformanceScenarios { new String[] { "should-retry:0:true", "retry-schedule:0", + }, + new String[][] { + new String[0], + new String[0], } ) ), diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 928c677..1f4c265 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1582,6 +1582,7 @@ static final class GeneratedTusClientConformanceScenario { final String[] primitives; final GeneratedTusClientConformanceEventPolicy eventPolicy; final String[] eventKeys; + final String[][] eventKeyAlternativeGroups; GeneratedTusClientConformanceScenario( String behavior, @@ -1600,6 +1601,7 @@ static final class GeneratedTusClientConformanceScenario { this.primitives = primitives; this.eventPolicy = events.policy; this.eventKeys = events.keys; + this.eventKeyAlternativeGroups = events.alternativeGroups; } } @@ -1609,12 +1611,15 @@ static final class GeneratedTusClientConformanceScenario { static final class GeneratedTusClientConformanceEvents { final GeneratedTusClientConformanceEventPolicy policy; final String[] keys; + final String[][] alternativeGroups; GeneratedTusClientConformanceEvents( GeneratedTusClientConformanceEventPolicy policy, - String[] keys) { + String[] keys, + String[][] alternativeGroups) { this.policy = policy; this.keys = keys; + this.alternativeGroups = alternativeGroups; } } From ece947cfc769576fbdde5020743ddecf686516d8 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 04:37:17 +0200 Subject: [PATCH 43/63] Regenerate TUS extra event prefixes --- ...eneratedTusClientConformanceScenarios.java | 114 +++++++++++++----- .../client/GeneratedTusProtocolContract.java | 7 +- 2 files changed, 93 insertions(+), 28 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java index d0e4b21..dee73ff 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java @@ -58,6 +58,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -96,6 +99,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -149,6 +155,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -186,6 +195,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -238,6 +250,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -278,6 +293,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -301,7 +319,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -324,7 +343,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -347,7 +367,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -370,7 +391,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -393,7 +415,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -416,7 +439,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -439,7 +463,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -462,7 +487,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -485,7 +511,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -508,7 +535,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -533,7 +561,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -558,7 +587,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -584,7 +614,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -610,7 +641,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -637,7 +669,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -687,6 +720,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -727,6 +763,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -761,7 +800,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -795,7 +835,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -829,7 +870,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -863,7 +905,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -897,7 +940,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -938,6 +982,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -1004,6 +1051,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -1030,7 +1080,8 @@ final class GeneratedTusClientConformanceScenarios { null ), new String[0], - new String[0][0] + new String[0][0], + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -1070,6 +1121,9 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], + }, + new String[] { + "progress:", } ) ), @@ -1106,7 +1160,8 @@ final class GeneratedTusClientConformanceScenarios { }, new String[][] { new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -1147,7 +1202,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -1182,7 +1238,8 @@ final class GeneratedTusClientConformanceScenarios { new String[0], new String[0], new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -1211,7 +1268,8 @@ final class GeneratedTusClientConformanceScenarios { }, new String[][] { new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -1243,7 +1301,8 @@ final class GeneratedTusClientConformanceScenarios { }, new String[][] { new String[0], - } + }, + new String[0] ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( @@ -1278,7 +1337,8 @@ final class GeneratedTusClientConformanceScenarios { new String[][] { new String[0], new String[0], - } + }, + new String[0] ) ), }; diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 1f4c265..34a6e13 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1583,6 +1583,7 @@ static final class GeneratedTusClientConformanceScenario { final GeneratedTusClientConformanceEventPolicy eventPolicy; final String[] eventKeys; final String[][] eventKeyAlternativeGroups; + final String[] eventKeyExtraPrefixes; GeneratedTusClientConformanceScenario( String behavior, @@ -1602,6 +1603,7 @@ static final class GeneratedTusClientConformanceScenario { this.eventPolicy = events.policy; this.eventKeys = events.keys; this.eventKeyAlternativeGroups = events.alternativeGroups; + this.eventKeyExtraPrefixes = events.extraPrefixes; } } @@ -1612,14 +1614,17 @@ static final class GeneratedTusClientConformanceEvents { final GeneratedTusClientConformanceEventPolicy policy; final String[] keys; final String[][] alternativeGroups; + final String[] extraPrefixes; GeneratedTusClientConformanceEvents( GeneratedTusClientConformanceEventPolicy policy, String[] keys, - String[][] alternativeGroups) { + String[][] alternativeGroups, + String[] extraPrefixes) { this.policy = policy; this.keys = keys; this.alternativeGroups = alternativeGroups; + this.extraPrefixes = extraPrefixes; } } From 3cf4810c0f1c2865f8a2ee96766f0a9cbeca23bd Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 05:30:04 +0200 Subject: [PATCH 44/63] Use generic TUS extra event matching policy --- ...eneratedTusClientConformanceScenarios.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java index dee73ff..d51316c 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java @@ -34,7 +34,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -81,7 +81,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -123,7 +123,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -177,7 +177,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -218,7 +218,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -273,7 +273,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -692,7 +692,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -743,7 +743,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" @@ -962,7 +962,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", "allow-known-total-before-declaration", "milestone", "may-emit-extra-samples" @@ -1007,7 +1007,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", "allow-known-total-before-declaration", "milestone", "may-emit-extra-samples" @@ -1105,7 +1105,7 @@ final class GeneratedTusClientConformanceScenarios { }, new GeneratedTusProtocolContract.GeneratedTusClientConformanceEvents( new GeneratedTusProtocolContract.GeneratedTusClientConformanceEventPolicy( - "exact-except-extra-progress", + "exact-except-allowed-extra-events", null, "milestone", "may-emit-extra-samples" From 8033f50d7db7146d7db30a3a66eaf29dec32bac0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 17:25:36 +0200 Subject: [PATCH 45/63] Regenerate TUS conformance metadata fixtures --- ...eneratedTusClientConformanceScenarios.java | 380 +++++++++--------- .../client/GeneratedTusProtocolContract.java | 51 ++- 2 files changed, 220 insertions(+), 211 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java index d51316c..3694e2a 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusClientConformanceScenarios.java @@ -13,13 +13,13 @@ final class GeneratedTusClientConformanceScenarios { static final GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario[] CLIENT_CONFORMANCE_SCENARIOS = new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario[] { new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "single-upload-lifecycle", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "single-upload-lifecycle", "success", - null + null, + "singleUploadLifecycle", + "singleUploadLifecycle" ), - "singleUploadLifecycle", - "singleUploadLifecycle", new String[] { "createTusUpload", "patchTusUpload", @@ -65,13 +65,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "creation-with-upload", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "creation-with-upload", "success", - null + null, + "creationWithUpload", + "creationWithUpload" ), - "creationWithUpload", - "creationWithUpload", new String[] { "createTusUpload", }, @@ -106,13 +106,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "creation-with-upload-partial-chunk", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "creation-with-upload-partial-chunk", "success", - null + null, + "creationWithUpload", + "creationWithUploadPartialChunk" ), - "creationWithUpload", - "creationWithUploadPartialChunk", new String[] { "createTusUpload", "patchTusUpload", @@ -162,13 +162,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "creation-with-upload", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "creation-with-upload", "success", - null + null, + "protocolVersionSelection", + "ietfDraft05CreationWithUpload" ), - "protocolVersionSelection", - "ietfDraft05CreationWithUpload", new String[] { "createTusUpload", }, @@ -202,13 +202,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "upload-body-headers", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "upload-body-headers", "success", - null + null, + "protocolVersionSelection", + "ietfDraft05ChunkedUploadComplete" ), - "protocolVersionSelection", - "ietfDraft05ChunkedUploadComplete", new String[] { "getTusUploadOffset", "patchTusUpload", @@ -257,13 +257,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "upload-body-headers", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "upload-body-headers", "success", - null + null, + "protocolVersionSelection", + "ietfDraft03ResumeWithoutKnownLength" ), - "protocolVersionSelection", - "ietfDraft03ResumeWithoutKnownLength", new String[] { "getTusUploadOffset", "patchTusUpload", @@ -300,13 +300,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", "error", - "missingInput" + "missingInput", + "startOptionValidation", + "startValidationMissingInput" ), - "startOptionValidation", - "startValidationMissingInput", new String[0], new String[] { "validate-start-options", @@ -324,13 +324,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", "error", - "missingEndpointOrUploadUrl" + "missingEndpointOrUploadUrl", + "startOptionValidation", + "startValidationMissingEndpointOrUploadUrl" ), - "startOptionValidation", - "startValidationMissingEndpointOrUploadUrl", new String[0], new String[] { "validate-start-options", @@ -348,13 +348,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", "error", - "unsupportedProtocol" + "unsupportedProtocol", + "startOptionValidation", + "startValidationUnsupportedProtocol" ), - "startOptionValidation", - "startValidationUnsupportedProtocol", new String[0], new String[] { "validate-start-options", @@ -372,13 +372,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", "error", - "retryDelaysNotArray" + "retryDelaysNotArray", + "startOptionValidation", + "startValidationRetryDelaysNotArray" ), - "startOptionValidation", - "startValidationRetryDelaysNotArray", new String[0], new String[] { "validate-start-options", @@ -396,13 +396,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", "error", - "parallelUploadsWithUploadUrl" + "parallelUploadsWithUploadUrl", + "startOptionValidation", + "startValidationParallelUploadsWithUploadUrl" ), - "startOptionValidation", - "startValidationParallelUploadsWithUploadUrl", new String[0], new String[] { "validate-start-options", @@ -420,13 +420,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", "error", - "parallelUploadsWithUploadSize" + "parallelUploadsWithUploadSize", + "startOptionValidation", + "startValidationParallelUploadsWithUploadSize" ), - "startOptionValidation", - "startValidationParallelUploadsWithUploadSize", new String[0], new String[] { "validate-start-options", @@ -444,13 +444,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", "error", - "parallelUploadsWithDeferredLength" + "parallelUploadsWithDeferredLength", + "startOptionValidation", + "startValidationParallelUploadsWithDeferredLength" ), - "startOptionValidation", - "startValidationParallelUploadsWithDeferredLength", new String[0], new String[] { "validate-start-options", @@ -468,13 +468,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", "error", - "parallelUploadsWithUploadDataDuringCreation" + "parallelUploadsWithUploadDataDuringCreation", + "startOptionValidation", + "startValidationParallelUploadsWithUploadDataDuringCreation" ), - "startOptionValidation", - "startValidationParallelUploadsWithUploadDataDuringCreation", new String[0], new String[] { "validate-start-options", @@ -492,13 +492,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", "error", - "parallelBoundariesWithoutParallelUploads" + "parallelBoundariesWithoutParallelUploads", + "startOptionValidation", + "startValidationParallelBoundariesWithoutParallelUploads" ), - "startOptionValidation", - "startValidationParallelBoundariesWithoutParallelUploads", new String[0], new String[] { "validate-start-options", @@ -516,13 +516,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "start-option-validation", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "start-option-validation", "error", - "parallelBoundariesLengthMismatch" + "parallelBoundariesLengthMismatch", + "startOptionValidation", + "startValidationParallelBoundariesLengthMismatch" ), - "startOptionValidation", - "startValidationParallelBoundariesLengthMismatch", new String[0], new String[] { "validate-start-options", @@ -540,13 +540,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "detailed-error", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "detailed-error", "error", - "unexpectedCreateResponse" + "unexpectedCreateResponse", + "detailedErrors", + "detailedCreateResponseError" ), - "detailedErrors", - "detailedCreateResponseError", new String[] { "createTusUpload", }, @@ -566,13 +566,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "detailed-error", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "detailed-error", "error", - "createUploadRequestFailed" + "createUploadRequestFailed", + "detailedErrors", + "detailedCreateRequestError" ), - "detailedErrors", - "detailedCreateRequestError", new String[] { "createTusUpload", }, @@ -592,13 +592,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "upload-body-headers", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "upload-body-headers", "success", - null + null, + "uploadBodyHeaders", + "uploadBodyHeaders" ), - "uploadBodyHeaders", - "uploadBodyHeaders", new String[] { "createTusUpload", "patchTusUpload", @@ -619,13 +619,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "custom-request-headers", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "custom-request-headers", "success", - null + null, + "customRequestHeaders", + "customRequestHeaders" ), - "customRequestHeaders", - "customRequestHeaders", new String[] { "createTusUpload", "patchTusUpload", @@ -646,13 +646,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "request-id-headers", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "request-id-headers", "success", - null + null, + "requestIdHeaders", + "requestIdHeaders" ), - "requestIdHeaders", - "requestIdHeaders", new String[] { "createTusUpload", "patchTusUpload", @@ -674,13 +674,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "resume-from-previous-upload", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "resume-from-previous-upload", "success", - null + null, + "resumeUpload", + "resumeFromPreviousUpload" ), - "resumeUpload", - "resumeFromPreviousUpload", new String[] { "getTusUploadOffset", "patchTusUpload", @@ -727,13 +727,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "relative-location-resolution", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "relative-location-resolution", "success", - null + null, + "relativeLocationResolution", + "relativeLocationResolution" ), - "relativeLocationResolution", - "relativeLocationResolution", new String[] { "createTusUpload", "patchTusUpload", @@ -770,13 +770,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "array-buffer-input", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "array-buffer-input", "success", - null + null, + "inputSources", + "arrayBufferInput" ), - "inputSources", - "arrayBufferInput", new String[] { "createTusUpload", "patchTusUpload", @@ -805,13 +805,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "array-buffer-view-input", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "array-buffer-view-input", "success", - null + null, + "inputSources", + "arrayBufferViewInput" ), - "inputSources", - "arrayBufferViewInput", new String[] { "createTusUpload", "patchTusUpload", @@ -840,13 +840,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "web-readable-stream-input", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "web-readable-stream-input", "success", - null + null, + "inputSources", + "webReadableStreamInput" ), - "inputSources", - "webReadableStreamInput", new String[] { "createTusUpload", "patchTusUpload", @@ -875,13 +875,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "node-readable-stream-input", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "node-readable-stream-input", "success", - null + null, + "inputSources", + "nodeReadableStreamInput" ), - "inputSources", - "nodeReadableStreamInput", new String[] { "createTusUpload", "patchTusUpload", @@ -910,13 +910,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "node-path-input", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "node-path-input", "success", - null + null, + "inputSources", + "nodePathInput" ), - "inputSources", - "nodePathInput", new String[] { "createTusUpload", "patchTusUpload", @@ -945,13 +945,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "deferred-length-upload", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "deferred-length-upload", "success", - null + null, + "deferredLengthUpload", + "deferredLengthUpload" ), - "deferredLengthUpload", - "deferredLengthUpload", new String[] { "createTusUpload", "patchTusUpload", @@ -989,13 +989,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "deferred-length-upload", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "deferred-length-upload", "success", - null + null, + "deferredLengthUpload", + "deferredLengthChunkedUpload" ), - "deferredLengthUpload", - "deferredLengthChunkedUpload", new String[] { "createTusUpload", "patchTusUpload", @@ -1058,13 +1058,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "override-patch-method", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "override-patch-method", "success", - null + null, + "overridePatchMethod", + "overridePatchMethod" ), - "overridePatchMethod", - "overridePatchMethod", new String[] { "getTusUploadOffset", "patchTusUpload", @@ -1085,13 +1085,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "parallel-upload-concat", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "parallel-upload-concat", "success", - null + null, + "parallelUploadConcat", + "parallelUploadConcat" ), - "parallelUploadConcat", - "parallelUploadConcat", new String[] { "createTusUpload", "createTusUpload", @@ -1128,13 +1128,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "parallel-upload-abort-cleanup", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "parallel-upload-abort-cleanup", "aborted", - null + null, + "parallelUploadConcat", + "parallelUploadAbortCleanup" ), - "parallelUploadConcat", - "parallelUploadAbortCleanup", new String[] { "createTusUpload", "createTusUpload", @@ -1165,13 +1165,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "retry-patch-after-offset-recovery", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "retry-patch-after-offset-recovery", "success", - null + null, + "retryOffsetRecovery", + "retryPatchAfterOffsetRecovery" ), - "retryOffsetRecovery", - "retryPatchAfterOffsetRecovery", new String[] { "createTusUpload", "patchTusUpload", @@ -1207,13 +1207,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "request-lifecycle-hooks", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "request-lifecycle-hooks", "success", - null + null, + "requestLifecycleHooks", + "requestLifecycleHooks" ), - "requestLifecycleHooks", - "requestLifecycleHooks", new String[] { "getTusUploadOffset", }, @@ -1243,13 +1243,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "abort-upload", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "abort-upload", "aborted", - null + null, + "abortUpload", + "abortUpload" ), - "abortUpload", - "abortUpload", new String[] { "createTusUpload", }, @@ -1273,13 +1273,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "abort-upload-after-stored-url", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "abort-upload-after-stored-url", "aborted", - null + null, + "abortUpload", + "abortUploadAfterStoredUrl" ), - "abortUpload", - "abortUploadAfterStoredUrl", new String[] { "createTusUpload", "patchTusUpload", @@ -1306,13 +1306,13 @@ final class GeneratedTusClientConformanceScenarios { ) ), new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenario( - "terminate-with-retry", - new GeneratedTusProtocolContract.GeneratedTusClientConformanceCompletion( + new GeneratedTusProtocolContract.GeneratedTusClientConformanceScenarioMetadata( + "terminate-with-retry", "terminated", - null + null, + "terminateUpload", + "terminateWithRetry" ), - "terminateUpload", - "terminateWithRetry", new String[] { "createTusUpload", "patchTusUpload", diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 34a6e13..6bd1301 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1569,6 +1569,30 @@ static final class GeneratedTusManagedUploadProofCase { } } + /** + * Generated client conformance scenario metadata. + */ + static final class GeneratedTusClientConformanceScenarioMetadata { + final String behavior; + final String completionKind; + final String completionReason; + final String featureId; + final String scenarioId; + + GeneratedTusClientConformanceScenarioMetadata( + String behavior, + String completionKind, + String completionReason, + String featureId, + String scenarioId) { + this.behavior = behavior; + this.completionKind = completionKind; + this.completionReason = completionReason; + this.featureId = featureId; + this.scenarioId = scenarioId; + } + } + /** * Generated client conformance scenario fixture. */ @@ -1586,18 +1610,15 @@ static final class GeneratedTusClientConformanceScenario { final String[] eventKeyExtraPrefixes; GeneratedTusClientConformanceScenario( - String behavior, - GeneratedTusClientConformanceCompletion completion, - String featureId, - String scenarioId, + GeneratedTusClientConformanceScenarioMetadata metadata, String[] operationIds, String[] primitives, GeneratedTusClientConformanceEvents events) { - this.behavior = behavior; - this.completionKind = completion.kind; - this.completionReason = completion.reason; - this.featureId = featureId; - this.scenarioId = scenarioId; + this.behavior = metadata.behavior; + this.completionKind = metadata.completionKind; + this.completionReason = metadata.completionReason; + this.featureId = metadata.featureId; + this.scenarioId = metadata.scenarioId; this.operationIds = operationIds; this.primitives = primitives; this.eventPolicy = events.policy; @@ -1649,16 +1670,4 @@ static final class GeneratedTusClientConformanceEventPolicy { } } - /** - * Generated client conformance completion fixture. - */ - static final class GeneratedTusClientConformanceCompletion { - final String kind; - final String reason; - - GeneratedTusClientConformanceCompletion(String kind, String reason) { - this.kind = kind; - this.reason = reason; - } - } } From 407537df282095856bf1c133177480f771505f89 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 21:32:01 +0200 Subject: [PATCH 46/63] Update managed upload runtime capabilities fixture --- .../TestGeneratedTusManagedUploadRuntime.java | 480 +++++++++--------- 1 file changed, 234 insertions(+), 246 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index b7680b7..eac0331 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -56,17 +56,13 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadRuntimeCase[] { new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( - "managedUploadDurableRetry", - "android", - "durable-os-scheduler", - "copy-to-owned-storage", - "available", - "platform-key-value-store", - new GeneratedTusManagedUploadNetwork( - "any-network", - "unmetered-network", - "start-upload-work" - ) + "managedUploadDurableRetry" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + true, + false, + true ), new GeneratedTusManagedUploadTransport( "Location" @@ -74,12 +70,22 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadOutcome( "terminal", "succeeded", - "", "" ), - new GeneratedTusManagedUploadCleanup( - "remove-owned-source-after-success", - "remove-after-success" + new GeneratedTusManagedUploadExecution( + true, + false, + false, + false, + true, + true, + false, + false + ), + new GeneratedTusManagedUploadStateExpectations( + true, + false, + false ), new GeneratedTusManagedUploadRetryPlan( new String[] { @@ -235,17 +241,13 @@ public class TestGeneratedTusManagedUploadRuntime { ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( - "managedUploadPermanentFailure", - "android", - "durable-os-scheduler", - "copy-to-owned-storage", - "available", - "platform-key-value-store", - new GeneratedTusManagedUploadNetwork( - "any-network", - "unmetered-network", - "start-upload-work" - ) + "managedUploadPermanentFailure" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + true, + false, + true ), new GeneratedTusManagedUploadTransport( "Location" @@ -253,12 +255,22 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadOutcome( "terminal", "failed", - "unretryable-protocol-error", "" ), - new GeneratedTusManagedUploadCleanup( - "retain-owned-source-after-permanent-failure", - "absent-after-permanent-failure" + new GeneratedTusManagedUploadExecution( + false, + false, + false, + true, + true, + true, + false, + false + ), + new GeneratedTusManagedUploadStateExpectations( + true, + true, + false ), new GeneratedTusManagedUploadRetryPlan( new String[] { @@ -319,17 +331,13 @@ public class TestGeneratedTusManagedUploadRuntime { ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( - "managedUploadRetryPolicyExhausted", - "android", - "durable-os-scheduler", - "copy-to-owned-storage", - "available", - "platform-key-value-store", - new GeneratedTusManagedUploadNetwork( - "any-network", - "unmetered-network", - "start-upload-work" - ) + "managedUploadRetryPolicyExhausted" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + true, + false, + true ), new GeneratedTusManagedUploadTransport( "Location" @@ -337,12 +345,22 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadOutcome( "terminal", "failed", - "retry-policy-exhausted", "" ), - new GeneratedTusManagedUploadCleanup( - "retain-owned-source-after-permanent-failure", - "absent-after-permanent-failure" + new GeneratedTusManagedUploadExecution( + false, + false, + true, + true, + true, + true, + false, + false + ), + new GeneratedTusManagedUploadStateExpectations( + true, + true, + false ), new GeneratedTusManagedUploadRetryPlan( new String[] { @@ -478,17 +496,13 @@ public class TestGeneratedTusManagedUploadRuntime { ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( - "managedUploadSourceUnavailable", - "android", - "durable-os-scheduler", - "copy-to-owned-storage", - "missing-before-durable-copy", - "platform-key-value-store", - new GeneratedTusManagedUploadNetwork( - "any-network", - "unmetered-network", - "start-upload-work" - ) + "managedUploadSourceUnavailable" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + true, + false, + true ), new GeneratedTusManagedUploadTransport( "Location" @@ -496,12 +510,22 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadOutcome( "terminal", "failed", - "source-unavailable", "" ), - new GeneratedTusManagedUploadCleanup( - "absent-after-source-unavailable", - "absent-after-permanent-failure" + new GeneratedTusManagedUploadExecution( + false, + false, + true, + false, + true, + false, + true, + true + ), + new GeneratedTusManagedUploadStateExpectations( + false, + false, + false ), new GeneratedTusManagedUploadRetryPlan( new String[] { @@ -540,17 +564,13 @@ public class TestGeneratedTusManagedUploadRuntime { ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( - "managedUploadNetworkConstraint", - "android", - "durable-os-scheduler", - "copy-to-owned-storage", - "available", - "platform-key-value-store", - new GeneratedTusManagedUploadNetwork( - "unmetered-network", - "metered-network", - "defer-until-network-constraint-satisfied" - ) + "managedUploadNetworkConstraint" + ), + new GeneratedTusManagedUploadRuntimeCapabilities( + true, + true, + false, + true ), new GeneratedTusManagedUploadTransport( "Location" @@ -558,12 +578,22 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadOutcome( "deferred", "pending", - "", "network-constraint-unsatisfied" ), - new GeneratedTusManagedUploadCleanup( - "retain-owned-source-while-deferred", - "absent-while-deferred" + new GeneratedTusManagedUploadExecution( + false, + true, + false, + false, + false, + true, + false, + false + ), + new GeneratedTusManagedUploadStateExpectations( + true, + true, + false ), new GeneratedTusManagedUploadRetryPlan( new String[] { @@ -705,25 +735,19 @@ private void assertTerminalResult( private void assertTerminalFailure( GeneratedTusManagedUploadRuntimeCase testCase, Throwable error) { - if ("unretryable-protocol-error".equals(testCase.outcomeFailure)) { + if (testCase.expectProtocolExceptionOnTerminalFailure && error instanceof ProtocolException) { assertTrue(testCase.scenarioId, error instanceof ProtocolException); return; } - if ("source-unavailable".equals(testCase.outcomeFailure)) { + if (testCase.expectIoExceptionOnTerminalFailure && error instanceof IOException) { assertTrue(testCase.scenarioId, error instanceof IOException); return; } - if ("retry-policy-exhausted".equals(testCase.outcomeFailure)) { - assertTrue( - testCase.scenarioId, - error instanceof ProtocolException || error instanceof IOException); - return; - } throw new AssertionError( testCase.scenarioId - + " uses unsupported generated terminal failure " - + testCase.outcomeFailure); + + " observed unexpected generated terminal failure " + + error); } private void assertDeferredResult(GeneratedTusManagedUploadRuntimeCase testCase) { @@ -731,30 +755,12 @@ private void assertDeferredResult(GeneratedTusManagedUploadRuntimeCase testCase) !"deferred".equals(testCase.outcomeKind) || !"pending".equals(testCase.outcomeState) || !"network-constraint-unsatisfied".equals(testCase.outcomeReason) - || !"defer-until-network-constraint-satisfied".equals(testCase.networkDecision) - || networkConstraintSatisfied(testCase)) { + || !testCase.deferBeforeProtocol + || testCase.networkConstraintSatisfied) { throw new AssertionError(testCase.scenarioId + " expected deferred network outcome"); } } - private boolean networkConstraintSatisfied(GeneratedTusManagedUploadRuntimeCase testCase) { - if ("offline".equals(testCase.currentNetwork)) { - return false; - } - if ("any-network".equals(testCase.networkRequired)) { - return "metered-network".equals(testCase.currentNetwork) - || "unmetered-network".equals(testCase.currentNetwork); - } - if ("unmetered-network".equals(testCase.networkRequired)) { - return "unmetered-network".equals(testCase.currentNetwork); - } - - throw new AssertionError( - testCase.scenarioId - + " uses unsupported generated network requirement " - + testCase.networkRequired); - } - private TusExecutor managedExecutorFor( final GeneratedTusManagedUploadRuntimeCase testCase, final TusClient client, @@ -838,11 +844,10 @@ private void copyDurableSource( GeneratedTusManagedUploadRuntimeCase testCase, File source, File ownedSource) throws IOException { - if (!"copy-to-owned-storage".equals(testCase.sourceDurability)) { + if (!testCase.copySourceToOwnedStorage) { throw new AssertionError( testCase.scenarioId - + " uses unsupported generated source durability " - + testCase.sourceDurability); + + " uses unsupported generated source durability capability"); } copyFile(source, ownedSource); @@ -855,11 +860,11 @@ private void prepareSourceBeforeProtocol( File ownedSource, List states, SharedPreferences stateStore) throws IOException { - if ("available".equals(testCase.sourceAvailability)) { + if (testCase.prepareDurableSourceBeforeProtocol) { copyDurableSource(testCase, source, ownedSource); return; } - if ("missing-before-durable-copy".equals(testCase.sourceAvailability)) { + if (testCase.simulateMissingSourceBeforeDurableCopy) { GeneratedTusManagedUploadAttempt attempt = testCase.attempts[0]; if (source.exists() && !source.delete()) { throw new IOException("Could not remove generated input source " + source); @@ -876,23 +881,21 @@ private void prepareSourceBeforeProtocol( throw new AssertionError( testCase.scenarioId - + " uses unsupported generated source availability " - + testCase.sourceAvailability); + + " uses unsupported generated source preparation expectations"); } private boolean isSourceUnavailableBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { - return "source-unavailable".equals(testCase.outcomeFailure) - && "missing-before-durable-copy".equals(testCase.sourceAvailability); + return testCase.sourceUnavailableBeforeProtocol; } private boolean shouldDeferBeforeProtocol(GeneratedTusManagedUploadRuntimeCase testCase) { - return "defer-until-network-constraint-satisfied".equals(testCase.networkDecision); + return testCase.deferBeforeProtocol; } private void cleanupAfterTerminalState( GeneratedTusManagedUploadRuntimeCase testCase, File ownedSource) throws IOException { - if (!"remove-owned-source-after-success".equals(testCase.ownedSourceCleanup)) { + if (!testCase.cleanupOwnedSourceAfterTerminalState) { return; } @@ -904,58 +907,36 @@ private void cleanupAfterTerminalState( private void assertOwnedSourceState( GeneratedTusManagedUploadRuntimeCase testCase, File ownedSource) { - if ("remove-owned-source-after-success".equals(testCase.ownedSourceCleanup)) { - assertFalse(testCase.scenarioId, ownedSource.exists()); - return; - } - if ("retain-owned-source-after-permanent-failure".equals(testCase.ownedSourceCleanup)) { + if (testCase.expectOwnedSourceExists) { assertTrue(testCase.scenarioId, ownedSource.exists()); ownedSource.delete(); return; } - if ("retain-owned-source-while-deferred".equals(testCase.ownedSourceCleanup)) { - assertTrue(testCase.scenarioId, ownedSource.exists()); - ownedSource.delete(); - return; - } - if ("absent-after-source-unavailable".equals(testCase.ownedSourceCleanup)) { - assertFalse(testCase.scenarioId, ownedSource.exists()); - return; - } - throw new AssertionError( - testCase.scenarioId - + " uses unsupported generated owned-source cleanup " - + testCase.ownedSourceCleanup); + assertFalse(testCase.scenarioId, ownedSource.exists()); } private void assertInputSourceState( GeneratedTusManagedUploadRuntimeCase testCase, File source) { - if ("missing-before-durable-copy".equals(testCase.sourceAvailability)) { - assertFalse(testCase.scenarioId, source.exists()); + if (testCase.expectInputSourceExists) { + assertTrue(testCase.scenarioId, source.exists()); + source.delete(); return; } - assertTrue(testCase.scenarioId, source.exists()); - source.delete(); + assertFalse(testCase.scenarioId, source.exists()); } private void assertResumeUrlState( GeneratedTusManagedUploadRuntimeCase testCase, TusPreferencesURLStore urlStore) { - if ( - "remove-after-success".equals(testCase.resumeUrlCleanup) - || "absent-after-permanent-failure".equals(testCase.resumeUrlCleanup) - || "absent-while-deferred".equals(testCase.resumeUrlCleanup)) { - assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); + if (testCase.expectResumeUrlExists) { + assertTrue(testCase.scenarioId, urlStore.get(testCase.input.fingerprint) != null); return; } - throw new AssertionError( - testCase.scenarioId - + " uses unsupported generated resume URL cleanup " - + testCase.resumeUrlCleanup); + assertNull(testCase.scenarioId, urlStore.get(testCase.input.fingerprint)); } private void assertProtocolRequestCount( @@ -979,11 +960,10 @@ private void recordState( List states, SharedPreferences stateStore, String state) { - if (!"platform-key-value-store".equals(testCase.stateBackend)) { + if (!testCase.usePlatformKeyValueStateBackend) { throw new AssertionError( testCase.scenarioId - + " uses unsupported generated state backend " - + testCase.stateBackend); + + " uses unsupported generated state backend capability"); } states.add(state); @@ -1063,59 +1043,40 @@ private static final class GeneratedTusAndroidScheduler { } Future submit(Callable work) { - if (!"durable-os-scheduler".equals(testCase.scheduler)) { + if (!testCase.useDurableOsScheduler) { throw new AssertionError( testCase.scenarioId - + " uses unsupported generated scheduler " - + testCase.scheduler); + + " uses unsupported generated scheduler capability"); } assertTrue( testCase.scenarioId, - stateStore.edit().putString("scheduler", testCase.scheduler).commit()); + stateStore.edit() + .putBoolean("durable-scheduler", testCase.useDurableOsScheduler) + .commit()); return worker.submit(work); } void deferUntilNetworkConstraintSatisfied() { - if (!"durable-os-scheduler".equals(testCase.scheduler)) { + if (!testCase.useDurableOsScheduler) { throw new AssertionError( testCase.scenarioId - + " uses unsupported generated scheduler " - + testCase.scheduler); + + " uses unsupported generated scheduler capability"); } if ( - !"defer-until-network-constraint-satisfied".equals(testCase.networkDecision) - || networkConstraintSatisfied(testCase)) { + !testCase.deferBeforeProtocol + || testCase.networkConstraintSatisfied) { throw new AssertionError(testCase.scenarioId + " expected unsatisfied network"); } assertTrue( testCase.scenarioId, stateStore.edit() - .putString("scheduler", testCase.scheduler) - .putString("network-required", testCase.networkRequired) - .putString("network-current", testCase.currentNetwork) + .putBoolean("durable-scheduler", testCase.useDurableOsScheduler) + .putBoolean("network-satisfied", testCase.networkConstraintSatisfied) .commit()); } - private boolean networkConstraintSatisfied(GeneratedTusManagedUploadRuntimeCase testCase) { - if ("offline".equals(testCase.currentNetwork)) { - return false; - } - if ("any-network".equals(testCase.networkRequired)) { - return "metered-network".equals(testCase.currentNetwork) - || "unmetered-network".equals(testCase.currentNetwork); - } - if ("unmetered-network".equals(testCase.networkRequired)) { - return "unmetered-network".equals(testCase.currentNetwork); - } - - throw new AssertionError( - testCase.scenarioId - + " uses unsupported generated network requirement " - + testCase.networkRequired); - } - void shutdown() { worker.shutdownNow(); } @@ -1484,21 +1445,25 @@ private static final class GeneratedTusHttpRequest { private static final class GeneratedTusManagedUploadRuntimeCase { final String scenarioId; - final String runtime; - final String scheduler; - final String sourceDurability; - final String sourceAvailability; - final String stateBackend; - final String networkRequired; - final String currentNetwork; - final String networkDecision; + final boolean copySourceToOwnedStorage; + final boolean useDurableOsScheduler; + final boolean useFilesystemStateBackend; + final boolean usePlatformKeyValueStateBackend; final String locationHeaderName; final String outcomeKind; final String outcomeState; - final String outcomeFailure; final String outcomeReason; - final String ownedSourceCleanup; - final String resumeUrlCleanup; + final boolean cleanupOwnedSourceAfterTerminalState; + final boolean deferBeforeProtocol; + final boolean expectIoExceptionOnTerminalFailure; + final boolean expectProtocolExceptionOnTerminalFailure; + final boolean networkConstraintSatisfied; + final boolean prepareDurableSourceBeforeProtocol; + final boolean simulateMissingSourceBeforeDurableCopy; + final boolean sourceUnavailableBeforeProtocol; + final boolean expectInputSourceExists; + final boolean expectOwnedSourceExists; + final boolean expectResumeUrlExists; final String[] expectedStates; final int[] retryDelays; final String offsetDiscoveryMethod; @@ -1507,28 +1472,35 @@ private static final class GeneratedTusManagedUploadRuntimeCase { GeneratedTusManagedUploadRuntimeCase( GeneratedTusManagedUploadRuntimeProfile profile, + GeneratedTusManagedUploadRuntimeCapabilities runtimeCapabilities, GeneratedTusManagedUploadTransport transport, GeneratedTusManagedUploadOutcome outcome, - GeneratedTusManagedUploadCleanup cleanup, + GeneratedTusManagedUploadExecution execution, + GeneratedTusManagedUploadStateExpectations stateExpectations, GeneratedTusManagedUploadRetryPlan retryPlan, GeneratedTusManagedUploadInput input, GeneratedTusManagedUploadAttempt[] attempts) { this.scenarioId = profile.scenarioId; - this.runtime = profile.runtime; - this.scheduler = profile.scheduler; - this.sourceDurability = profile.sourceDurability; - this.sourceAvailability = profile.sourceAvailability; - this.stateBackend = profile.stateBackend; - this.networkRequired = profile.networkRequired; - this.currentNetwork = profile.currentNetwork; - this.networkDecision = profile.networkDecision; + this.copySourceToOwnedStorage = runtimeCapabilities.copySourceToOwnedStorage; + this.useDurableOsScheduler = runtimeCapabilities.useDurableOsScheduler; + this.useFilesystemStateBackend = runtimeCapabilities.useFilesystemStateBackend; + this.usePlatformKeyValueStateBackend = + runtimeCapabilities.usePlatformKeyValueStateBackend; this.locationHeaderName = transport.locationHeaderName; this.outcomeKind = outcome.kind; this.outcomeState = outcome.state; - this.outcomeFailure = outcome.failure; this.outcomeReason = outcome.reason; - this.ownedSourceCleanup = cleanup.ownedSource; - this.resumeUrlCleanup = cleanup.resumeUrl; + this.cleanupOwnedSourceAfterTerminalState = execution.cleanupOwnedSourceAfterTerminalState; + this.deferBeforeProtocol = execution.deferBeforeProtocol; + this.expectIoExceptionOnTerminalFailure = execution.expectIoExceptionOnTerminalFailure; + this.expectProtocolExceptionOnTerminalFailure = execution.expectProtocolExceptionOnTerminalFailure; + this.networkConstraintSatisfied = execution.networkConstraintSatisfied; + this.prepareDurableSourceBeforeProtocol = execution.prepareDurableSourceBeforeProtocol; + this.simulateMissingSourceBeforeDurableCopy = execution.simulateMissingSourceBeforeDurableCopy; + this.sourceUnavailableBeforeProtocol = execution.sourceUnavailableBeforeProtocol; + this.expectInputSourceExists = stateExpectations.inputSourceExists; + this.expectOwnedSourceExists = stateExpectations.ownedSourceExists; + this.expectResumeUrlExists = stateExpectations.resumeUrlExists; this.expectedStates = retryPlan.expectedStates; this.retryDelays = retryPlan.retryDelays; this.offsetDiscoveryMethod = offsetDiscoveryMethod(); @@ -1540,57 +1512,38 @@ private static final class GeneratedTusManagedUploadRuntimeCase { private static final class GeneratedTusManagedUploadOutcome { final String kind; final String state; - final String failure; final String reason; - GeneratedTusManagedUploadOutcome(String kind, String state, String failure, String reason) { + GeneratedTusManagedUploadOutcome(String kind, String state, String reason) { this.kind = kind; this.state = state; - this.failure = failure; this.reason = reason; } } private static final class GeneratedTusManagedUploadRuntimeProfile { final String scenarioId; - final String runtime; - final String scheduler; - final String sourceDurability; - final String sourceAvailability; - final String stateBackend; - final String networkRequired; - final String currentNetwork; - final String networkDecision; - - GeneratedTusManagedUploadRuntimeProfile( - String scenarioId, - String runtime, - String scheduler, - String sourceDurability, - String sourceAvailability, - String stateBackend, - GeneratedTusManagedUploadNetwork network) { + + GeneratedTusManagedUploadRuntimeProfile(String scenarioId) { this.scenarioId = scenarioId; - this.runtime = runtime; - this.scheduler = scheduler; - this.sourceDurability = sourceDurability; - this.sourceAvailability = sourceAvailability; - this.stateBackend = stateBackend; - this.networkRequired = network.required; - this.currentNetwork = network.current; - this.networkDecision = network.decision; } } - private static final class GeneratedTusManagedUploadNetwork { - final String required; - final String current; - final String decision; - - GeneratedTusManagedUploadNetwork(String required, String current, String decision) { - this.required = required; - this.current = current; - this.decision = decision; + private static final class GeneratedTusManagedUploadRuntimeCapabilities { + final boolean copySourceToOwnedStorage; + final boolean useDurableOsScheduler; + final boolean useFilesystemStateBackend; + final boolean usePlatformKeyValueStateBackend; + + GeneratedTusManagedUploadRuntimeCapabilities( + boolean copySourceToOwnedStorage, + boolean useDurableOsScheduler, + boolean useFilesystemStateBackend, + boolean usePlatformKeyValueStateBackend) { + this.copySourceToOwnedStorage = copySourceToOwnedStorage; + this.useDurableOsScheduler = useDurableOsScheduler; + this.useFilesystemStateBackend = useFilesystemStateBackend; + this.usePlatformKeyValueStateBackend = usePlatformKeyValueStateBackend; } } @@ -1602,13 +1555,48 @@ private static final class GeneratedTusManagedUploadTransport { } } - private static final class GeneratedTusManagedUploadCleanup { - final String ownedSource; - final String resumeUrl; + private static final class GeneratedTusManagedUploadExecution { + final boolean cleanupOwnedSourceAfterTerminalState; + final boolean deferBeforeProtocol; + final boolean expectIoExceptionOnTerminalFailure; + final boolean expectProtocolExceptionOnTerminalFailure; + final boolean networkConstraintSatisfied; + final boolean prepareDurableSourceBeforeProtocol; + final boolean simulateMissingSourceBeforeDurableCopy; + final boolean sourceUnavailableBeforeProtocol; + + GeneratedTusManagedUploadExecution( + boolean cleanupOwnedSourceAfterTerminalState, + boolean deferBeforeProtocol, + boolean expectIoExceptionOnTerminalFailure, + boolean expectProtocolExceptionOnTerminalFailure, + boolean networkConstraintSatisfied, + boolean prepareDurableSourceBeforeProtocol, + boolean simulateMissingSourceBeforeDurableCopy, + boolean sourceUnavailableBeforeProtocol) { + this.cleanupOwnedSourceAfterTerminalState = cleanupOwnedSourceAfterTerminalState; + this.deferBeforeProtocol = deferBeforeProtocol; + this.expectIoExceptionOnTerminalFailure = expectIoExceptionOnTerminalFailure; + this.expectProtocolExceptionOnTerminalFailure = expectProtocolExceptionOnTerminalFailure; + this.networkConstraintSatisfied = networkConstraintSatisfied; + this.prepareDurableSourceBeforeProtocol = prepareDurableSourceBeforeProtocol; + this.simulateMissingSourceBeforeDurableCopy = simulateMissingSourceBeforeDurableCopy; + this.sourceUnavailableBeforeProtocol = sourceUnavailableBeforeProtocol; + } + } - GeneratedTusManagedUploadCleanup(String ownedSource, String resumeUrl) { - this.ownedSource = ownedSource; - this.resumeUrl = resumeUrl; + private static final class GeneratedTusManagedUploadStateExpectations { + final boolean inputSourceExists; + final boolean ownedSourceExists; + final boolean resumeUrlExists; + + GeneratedTusManagedUploadStateExpectations( + boolean inputSourceExists, + boolean ownedSourceExists, + boolean resumeUrlExists) { + this.inputSourceExists = inputSourceExists; + this.ownedSourceExists = ownedSourceExists; + this.resumeUrlExists = resumeUrlExists; } } From 90525e259be1b3bba9bb8d6e0e4ca160c8a20497 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 21:39:12 +0200 Subject: [PATCH 47/63] Update managed upload outcome fixture --- .../TestGeneratedTusManagedUploadRuntime.java | 95 +++++++++++-------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index eac0331..6ee7ac9 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -67,10 +67,11 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadOutcome( - "terminal", - "succeeded", - "" + new GeneratedTusManagedUploadOutcomeExpectations( + false, + false, + true, + true ), new GeneratedTusManagedUploadExecution( true, @@ -252,10 +253,11 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadOutcome( - "terminal", - "failed", - "" + new GeneratedTusManagedUploadOutcomeExpectations( + false, + true, + true, + false ), new GeneratedTusManagedUploadExecution( false, @@ -342,10 +344,11 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadOutcome( - "terminal", - "failed", - "" + new GeneratedTusManagedUploadOutcomeExpectations( + false, + true, + true, + false ), new GeneratedTusManagedUploadExecution( false, @@ -507,10 +510,11 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadOutcome( - "terminal", - "failed", - "" + new GeneratedTusManagedUploadOutcomeExpectations( + false, + true, + true, + false ), new GeneratedTusManagedUploadExecution( false, @@ -575,10 +579,11 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadTransport( "Location" ), - new GeneratedTusManagedUploadOutcome( - "deferred", - "pending", - "network-constraint-unsatisfied" + new GeneratedTusManagedUploadOutcomeExpectations( + true, + false, + false, + false ), new GeneratedTusManagedUploadExecution( false, @@ -714,18 +719,18 @@ public Boolean call() throws Exception { private void assertTerminalResult( GeneratedTusManagedUploadRuntimeCase testCase, Future future) throws Exception { - if (!"terminal".equals(testCase.outcomeKind)) { + if (!testCase.expectTerminalResult) { throw new AssertionError(testCase.scenarioId + " expected deferred outcome"); } try { boolean result = future.get(); - if (!"succeeded".equals(testCase.outcomeState)) { + if (!testCase.expectTerminalSuccess) { throw new AssertionError(testCase.scenarioId + " expected terminal failure"); } assertTrue(testCase.scenarioId, result); } catch (ExecutionException error) { - if (!"failed".equals(testCase.outcomeState)) { + if (!testCase.expectTerminalFailure) { throw error; } assertTerminalFailure(testCase, error.getCause()); @@ -752,9 +757,7 @@ private void assertTerminalFailure( private void assertDeferredResult(GeneratedTusManagedUploadRuntimeCase testCase) { if ( - !"deferred".equals(testCase.outcomeKind) - || !"pending".equals(testCase.outcomeState) - || !"network-constraint-unsatisfied".equals(testCase.outcomeReason) + !testCase.expectDeferredNetworkResult || !testCase.deferBeforeProtocol || testCase.networkConstraintSatisfied) { throw new AssertionError(testCase.scenarioId + " expected deferred network outcome"); @@ -1450,9 +1453,10 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final boolean useFilesystemStateBackend; final boolean usePlatformKeyValueStateBackend; final String locationHeaderName; - final String outcomeKind; - final String outcomeState; - final String outcomeReason; + final boolean expectDeferredNetworkResult; + final boolean expectTerminalFailure; + final boolean expectTerminalResult; + final boolean expectTerminalSuccess; final boolean cleanupOwnedSourceAfterTerminalState; final boolean deferBeforeProtocol; final boolean expectIoExceptionOnTerminalFailure; @@ -1474,7 +1478,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { GeneratedTusManagedUploadRuntimeProfile profile, GeneratedTusManagedUploadRuntimeCapabilities runtimeCapabilities, GeneratedTusManagedUploadTransport transport, - GeneratedTusManagedUploadOutcome outcome, + GeneratedTusManagedUploadOutcomeExpectations outcomeExpectations, GeneratedTusManagedUploadExecution execution, GeneratedTusManagedUploadStateExpectations stateExpectations, GeneratedTusManagedUploadRetryPlan retryPlan, @@ -1487,9 +1491,10 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.usePlatformKeyValueStateBackend = runtimeCapabilities.usePlatformKeyValueStateBackend; this.locationHeaderName = transport.locationHeaderName; - this.outcomeKind = outcome.kind; - this.outcomeState = outcome.state; - this.outcomeReason = outcome.reason; + this.expectDeferredNetworkResult = outcomeExpectations.expectDeferredNetworkResult; + this.expectTerminalFailure = outcomeExpectations.expectTerminalFailure; + this.expectTerminalResult = outcomeExpectations.expectTerminalResult; + this.expectTerminalSuccess = outcomeExpectations.expectTerminalSuccess; this.cleanupOwnedSourceAfterTerminalState = execution.cleanupOwnedSourceAfterTerminalState; this.deferBeforeProtocol = execution.deferBeforeProtocol; this.expectIoExceptionOnTerminalFailure = execution.expectIoExceptionOnTerminalFailure; @@ -1509,15 +1514,21 @@ private static final class GeneratedTusManagedUploadRuntimeCase { } } - private static final class GeneratedTusManagedUploadOutcome { - final String kind; - final String state; - final String reason; - - GeneratedTusManagedUploadOutcome(String kind, String state, String reason) { - this.kind = kind; - this.state = state; - this.reason = reason; + private static final class GeneratedTusManagedUploadOutcomeExpectations { + final boolean expectDeferredNetworkResult; + final boolean expectTerminalFailure; + final boolean expectTerminalResult; + final boolean expectTerminalSuccess; + + GeneratedTusManagedUploadOutcomeExpectations( + boolean expectDeferredNetworkResult, + boolean expectTerminalFailure, + boolean expectTerminalResult, + boolean expectTerminalSuccess) { + this.expectDeferredNetworkResult = expectDeferredNetworkResult; + this.expectTerminalFailure = expectTerminalFailure; + this.expectTerminalResult = expectTerminalResult; + this.expectTerminalSuccess = expectTerminalSuccess; } } From b80a6f5473716004d5d65f8c7e4566e30ecb2c69 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 21:44:37 +0200 Subject: [PATCH 48/63] Update managed upload attempt fixture --- .../TestGeneratedTusManagedUploadRuntime.java | 51 +++++++++++++------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 6ee7ac9..d5ca898 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -117,7 +117,9 @@ public class TestGeneratedTusManagedUploadRuntime { 0, "failed", new GeneratedTusManagedUploadFailure( - "after-accepted-offset", + true, + false, + false, "io-error", 7 ), @@ -299,7 +301,9 @@ public class TestGeneratedTusManagedUploadRuntime { 0, "failed", new GeneratedTusManagedUploadFailure( - "during-protocol-request", + false, + false, + true, "unretryable-protocol-error", -1 ), @@ -397,7 +401,9 @@ public class TestGeneratedTusManagedUploadRuntime { 0, "failed", new GeneratedTusManagedUploadFailure( - "during-protocol-request", + false, + false, + true, "retryable-protocol-error", -1 ), @@ -431,7 +437,9 @@ public class TestGeneratedTusManagedUploadRuntime { 1, "failed", new GeneratedTusManagedUploadFailure( - "during-protocol-request", + false, + false, + true, "retryable-protocol-error", -1 ), @@ -465,7 +473,9 @@ public class TestGeneratedTusManagedUploadRuntime { 2, "failed", new GeneratedTusManagedUploadFailure( - "during-protocol-request", + false, + false, + true, "retryable-protocol-error", -1 ), @@ -556,7 +566,9 @@ public class TestGeneratedTusManagedUploadRuntime { 0, "failed", new GeneratedTusManagedUploadFailure( - "before-protocol-request", + false, + true, + false, "source-unavailable", -1 ), @@ -791,7 +803,7 @@ protected void makeAttempt() throws ProtocolException, IOException { && uploader.getOffset() == attempt.failure.afterAcceptedOffset) { uploader.finish(false); recordState(testCase, states, stateStore, attempt.stateAfterAttempt); - throw new IOException(attempt.failure.kind); + throw new IOException(attempt.failure.failureMessage); } } uploader.finish(); @@ -811,7 +823,7 @@ protected void makeAttempt() throws ProtocolException, IOException { private boolean isAfterAcceptedOffsetFailure(GeneratedTusManagedUploadAttempt attempt) { return attempt.failure != null - && "after-accepted-offset".equals(attempt.failure.phase); + && attempt.failure.failAfterAcceptedOffset; } private void recordDuringProtocolFailure( @@ -819,7 +831,7 @@ private void recordDuringProtocolFailure( List states, SharedPreferences stateStore, GeneratedTusManagedUploadAttempt attempt) { - if (attempt.failure == null || !"during-protocol-request".equals(attempt.failure.phase)) { + if (attempt.failure == null || !attempt.failure.failDuringProtocolRequest) { return; } @@ -1661,14 +1673,23 @@ private static final class GeneratedTusManagedUploadAttempt { } private static final class GeneratedTusManagedUploadFailure { - final String phase; - final String kind; final long afterAcceptedOffset; - - GeneratedTusManagedUploadFailure(String phase, String kind, long afterAcceptedOffset) { - this.phase = phase; - this.kind = kind; + final boolean failAfterAcceptedOffset; + final boolean failBeforeProtocolRequest; + final boolean failDuringProtocolRequest; + final String failureMessage; + + GeneratedTusManagedUploadFailure( + boolean failAfterAcceptedOffset, + boolean failBeforeProtocolRequest, + boolean failDuringProtocolRequest, + String failureMessage, + long afterAcceptedOffset) { this.afterAcceptedOffset = afterAcceptedOffset; + this.failAfterAcceptedOffset = failAfterAcceptedOffset; + this.failBeforeProtocolRequest = failBeforeProtocolRequest; + this.failDuringProtocolRequest = failDuringProtocolRequest; + this.failureMessage = failureMessage; } } From 6d703fb0045a899c4f699e97dca4ca5fa96a7f58 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 21:54:17 +0200 Subject: [PATCH 49/63] Update managed upload state fixture --- .../TestGeneratedTusManagedUploadRuntime.java | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index d5ca898..22eaabc 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -65,6 +65,7 @@ public class TestGeneratedTusManagedUploadRuntime { true ), new GeneratedTusManagedUploadTransport( + "pending", "Location" ), new GeneratedTusManagedUploadOutcomeExpectations( @@ -115,6 +116,7 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, + "running", "failed", new GeneratedTusManagedUploadFailure( true, @@ -184,6 +186,7 @@ public class TestGeneratedTusManagedUploadRuntime { ), new GeneratedTusManagedUploadAttempt( 1, + "running", "succeeded", null, new GeneratedTusManagedUploadRequest[] { @@ -253,6 +256,7 @@ public class TestGeneratedTusManagedUploadRuntime { true ), new GeneratedTusManagedUploadTransport( + "pending", "Location" ), new GeneratedTusManagedUploadOutcomeExpectations( @@ -299,6 +303,7 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, + "running", "failed", new GeneratedTusManagedUploadFailure( false, @@ -346,6 +351,7 @@ public class TestGeneratedTusManagedUploadRuntime { true ), new GeneratedTusManagedUploadTransport( + "pending", "Location" ), new GeneratedTusManagedUploadOutcomeExpectations( @@ -399,6 +405,7 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, + "running", "failed", new GeneratedTusManagedUploadFailure( false, @@ -435,6 +442,7 @@ public class TestGeneratedTusManagedUploadRuntime { ), new GeneratedTusManagedUploadAttempt( 1, + "running", "failed", new GeneratedTusManagedUploadFailure( false, @@ -471,6 +479,7 @@ public class TestGeneratedTusManagedUploadRuntime { ), new GeneratedTusManagedUploadAttempt( 2, + "running", "failed", new GeneratedTusManagedUploadFailure( false, @@ -518,6 +527,7 @@ public class TestGeneratedTusManagedUploadRuntime { true ), new GeneratedTusManagedUploadTransport( + "pending", "Location" ), new GeneratedTusManagedUploadOutcomeExpectations( @@ -564,6 +574,7 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, + "running", "failed", new GeneratedTusManagedUploadFailure( false, @@ -589,6 +600,7 @@ public class TestGeneratedTusManagedUploadRuntime { true ), new GeneratedTusManagedUploadTransport( + "pending", "Location" ), new GeneratedTusManagedUploadOutcomeExpectations( @@ -665,7 +677,7 @@ public void shouldRunManagedUploadWithAndroidPlatformState() throws Exception { List states = new ArrayList(); File source = writeSourceFile(testCase); File ownedSource = ownedSourceFile(testCase, source); - recordState(testCase, states, stateStore, "pending"); + recordState(testCase, states, stateStore, testCase.initialState); final TusPreferencesURLStore urlStore = new TusPreferencesURLStore(urlStorePreferences); @@ -789,7 +801,7 @@ private TusExecutor managedExecutorFor( protected void makeAttempt() throws ProtocolException, IOException { GeneratedTusManagedUploadAttempt attempt = testCase.attempts[attemptIndex]; attemptIndex += 1; - recordState(testCase, states, stateStore, "running"); + recordState(testCase, states, stateStore, attempt.stateBeforeAttempt); try { TusUpload upload = uploadFor(testCase, ownedSource); @@ -884,7 +896,7 @@ private void prepareSourceBeforeProtocol( if (source.exists() && !source.delete()) { throw new IOException("Could not remove generated input source " + source); } - recordState(testCase, states, stateStore, "running"); + recordState(testCase, states, stateStore, attempt.stateBeforeAttempt); try { copyDurableSource(testCase, source, ownedSource); } catch (IOException error) { @@ -1464,6 +1476,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final boolean useDurableOsScheduler; final boolean useFilesystemStateBackend; final boolean usePlatformKeyValueStateBackend; + final String initialState; final String locationHeaderName; final boolean expectDeferredNetworkResult; final boolean expectTerminalFailure; @@ -1502,6 +1515,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.useFilesystemStateBackend = runtimeCapabilities.useFilesystemStateBackend; this.usePlatformKeyValueStateBackend = runtimeCapabilities.usePlatformKeyValueStateBackend; + this.initialState = transport.initialState; this.locationHeaderName = transport.locationHeaderName; this.expectDeferredNetworkResult = outcomeExpectations.expectDeferredNetworkResult; this.expectTerminalFailure = outcomeExpectations.expectTerminalFailure; @@ -1571,9 +1585,11 @@ private static final class GeneratedTusManagedUploadRuntimeCapabilities { } private static final class GeneratedTusManagedUploadTransport { + final String initialState; final String locationHeaderName; - GeneratedTusManagedUploadTransport(String locationHeaderName) { + GeneratedTusManagedUploadTransport(String initialState, String locationHeaderName) { + this.initialState = initialState; this.locationHeaderName = locationHeaderName; } } @@ -1657,16 +1673,19 @@ private static final class GeneratedTusManagedUploadInput { private static final class GeneratedTusManagedUploadAttempt { final int attemptIndex; final String stateAfterAttempt; + final String stateBeforeAttempt; final GeneratedTusManagedUploadFailure failure; final GeneratedTusManagedUploadRequest[] requests; GeneratedTusManagedUploadAttempt( int attemptIndex, + String stateBeforeAttempt, String stateAfterAttempt, GeneratedTusManagedUploadFailure failure, GeneratedTusManagedUploadRequest[] requests) { this.attemptIndex = attemptIndex; this.stateAfterAttempt = stateAfterAttempt; + this.stateBeforeAttempt = stateBeforeAttempt; this.failure = failure; this.requests = requests; } From 7b1c6b4c69e444cbfc1c4da510998e743f225ac7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 22:06:48 +0200 Subject: [PATCH 50/63] Group managed upload runtime fixture --- .../TestGeneratedTusManagedUploadRuntime.java | 376 +++++++++++------- 1 file changed, 228 insertions(+), 148 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 22eaabc..02b2c96 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -64,9 +64,19 @@ public class TestGeneratedTusManagedUploadRuntime { false, true ), - new GeneratedTusManagedUploadTransport( + new GeneratedTusManagedUploadRuntimePlan( + "Location", "pending", - "Location" + new String[] { + "pending", + "running", + "failed", + "running", + "succeeded", + }, + new int[] { + 0, + } ), new GeneratedTusManagedUploadOutcomeExpectations( false, @@ -75,33 +85,28 @@ public class TestGeneratedTusManagedUploadRuntime { true ), new GeneratedTusManagedUploadExecution( - true, - false, - false, - false, - true, - true, - false, - false + new GeneratedTusManagedUploadTerminalExecution( + true, + false, + false + ), + new GeneratedTusManagedUploadSchedulingExecution( + false, + true + ), + new GeneratedTusManagedUploadSourceExecution( + true, + false, + false + ) ), new GeneratedTusManagedUploadStateExpectations( true, false, false ), - new GeneratedTusManagedUploadRetryPlan( - new String[] { - "pending", - "running", - "failed", - "running", - "succeeded", - }, - new int[] { - 0, - } - ), - new GeneratedTusManagedUploadInput( + new GeneratedTusManagedUploadWorkload( + new GeneratedTusManagedUploadInput( "hello managed!", 7, "managed-durable-retry-fingerprint", @@ -112,8 +117,8 @@ public class TestGeneratedTusManagedUploadRuntime { "managed.txt" ), } - ), - new GeneratedTusManagedUploadAttempt[] { + ), + new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, "running", @@ -243,7 +248,8 @@ public class TestGeneratedTusManagedUploadRuntime { ), } ), - } + } + ) ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( @@ -255,9 +261,15 @@ public class TestGeneratedTusManagedUploadRuntime { false, true ), - new GeneratedTusManagedUploadTransport( + new GeneratedTusManagedUploadRuntimePlan( + "Location", + "pending", + new String[] { "pending", - "Location" + "running", + "failed", + }, + new int[0] ), new GeneratedTusManagedUploadOutcomeExpectations( false, @@ -266,29 +278,28 @@ public class TestGeneratedTusManagedUploadRuntime { false ), new GeneratedTusManagedUploadExecution( - false, - false, - false, - true, - true, - true, - false, - false + new GeneratedTusManagedUploadTerminalExecution( + false, + false, + true + ), + new GeneratedTusManagedUploadSchedulingExecution( + false, + true + ), + new GeneratedTusManagedUploadSourceExecution( + true, + false, + false + ) ), new GeneratedTusManagedUploadStateExpectations( true, true, false ), - new GeneratedTusManagedUploadRetryPlan( - new String[] { - "pending", - "running", - "failed", - }, - new int[0] - ), - new GeneratedTusManagedUploadInput( + new GeneratedTusManagedUploadWorkload( + new GeneratedTusManagedUploadInput( "hello failure!", 7, "managed-permanent-failure-fingerprint", @@ -299,8 +310,8 @@ public class TestGeneratedTusManagedUploadRuntime { "managed-permanent-failure.txt" ), } - ), - new GeneratedTusManagedUploadAttempt[] { + ), + new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, "running", @@ -338,7 +349,8 @@ public class TestGeneratedTusManagedUploadRuntime { ), } ), - } + } + ) ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( @@ -350,32 +362,9 @@ public class TestGeneratedTusManagedUploadRuntime { false, true ), - new GeneratedTusManagedUploadTransport( + new GeneratedTusManagedUploadRuntimePlan( + "Location", "pending", - "Location" - ), - new GeneratedTusManagedUploadOutcomeExpectations( - false, - true, - true, - false - ), - new GeneratedTusManagedUploadExecution( - false, - false, - true, - true, - true, - true, - false, - false - ), - new GeneratedTusManagedUploadStateExpectations( - true, - true, - false - ), - new GeneratedTusManagedUploadRetryPlan( new String[] { "pending", "running", @@ -390,7 +379,35 @@ public class TestGeneratedTusManagedUploadRuntime { 0, } ), - new GeneratedTusManagedUploadInput( + new GeneratedTusManagedUploadOutcomeExpectations( + false, + true, + true, + false + ), + new GeneratedTusManagedUploadExecution( + new GeneratedTusManagedUploadTerminalExecution( + false, + true, + true + ), + new GeneratedTusManagedUploadSchedulingExecution( + false, + true + ), + new GeneratedTusManagedUploadSourceExecution( + true, + false, + false + ) + ), + new GeneratedTusManagedUploadStateExpectations( + true, + true, + false + ), + new GeneratedTusManagedUploadWorkload( + new GeneratedTusManagedUploadInput( "hello retries!", 7, "managed-retry-exhausted-fingerprint", @@ -401,8 +418,8 @@ public class TestGeneratedTusManagedUploadRuntime { "managed-retry-exhausted.txt" ), } - ), - new GeneratedTusManagedUploadAttempt[] { + ), + new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, "running", @@ -514,7 +531,8 @@ public class TestGeneratedTusManagedUploadRuntime { ), } ), - } + } + ) ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( @@ -526,9 +544,15 @@ public class TestGeneratedTusManagedUploadRuntime { false, true ), - new GeneratedTusManagedUploadTransport( + new GeneratedTusManagedUploadRuntimePlan( + "Location", + "pending", + new String[] { "pending", - "Location" + "running", + "failed", + }, + new int[0] ), new GeneratedTusManagedUploadOutcomeExpectations( false, @@ -537,29 +561,28 @@ public class TestGeneratedTusManagedUploadRuntime { false ), new GeneratedTusManagedUploadExecution( - false, - false, - true, - false, - true, - false, - true, - true + new GeneratedTusManagedUploadTerminalExecution( + false, + true, + false + ), + new GeneratedTusManagedUploadSchedulingExecution( + false, + true + ), + new GeneratedTusManagedUploadSourceExecution( + false, + true, + true + ) ), new GeneratedTusManagedUploadStateExpectations( false, false, false ), - new GeneratedTusManagedUploadRetryPlan( - new String[] { - "pending", - "running", - "failed", - }, - new int[0] - ), - new GeneratedTusManagedUploadInput( + new GeneratedTusManagedUploadWorkload( + new GeneratedTusManagedUploadInput( "hello missing!", 7, "managed-source-unavailable-fingerprint", @@ -570,8 +593,8 @@ public class TestGeneratedTusManagedUploadRuntime { "managed-source-unavailable.txt" ), } - ), - new GeneratedTusManagedUploadAttempt[] { + ), + new GeneratedTusManagedUploadAttempt[] { new GeneratedTusManagedUploadAttempt( 0, "running", @@ -587,7 +610,8 @@ public class TestGeneratedTusManagedUploadRuntime { } ), - } + } + ) ), new GeneratedTusManagedUploadRuntimeCase( new GeneratedTusManagedUploadRuntimeProfile( @@ -599,9 +623,13 @@ public class TestGeneratedTusManagedUploadRuntime { false, true ), - new GeneratedTusManagedUploadTransport( + new GeneratedTusManagedUploadRuntimePlan( + "Location", + "pending", + new String[] { "pending", - "Location" + }, + new int[0] ), new GeneratedTusManagedUploadOutcomeExpectations( true, @@ -610,27 +638,28 @@ public class TestGeneratedTusManagedUploadRuntime { false ), new GeneratedTusManagedUploadExecution( - false, - true, - false, - false, - false, - true, - false, - false + new GeneratedTusManagedUploadTerminalExecution( + false, + false, + false + ), + new GeneratedTusManagedUploadSchedulingExecution( + true, + false + ), + new GeneratedTusManagedUploadSourceExecution( + true, + false, + false + ) ), new GeneratedTusManagedUploadStateExpectations( true, true, false ), - new GeneratedTusManagedUploadRetryPlan( - new String[] { - "pending", - }, - new int[0] - ), - new GeneratedTusManagedUploadInput( + new GeneratedTusManagedUploadWorkload( + new GeneratedTusManagedUploadInput( "hello later!", 7, "managed-network-constraint-fingerprint", @@ -641,10 +670,11 @@ public class TestGeneratedTusManagedUploadRuntime { "managed-network-constraint.txt" ), } - ), - new GeneratedTusManagedUploadAttempt[] { + ), + new GeneratedTusManagedUploadAttempt[] { - } + } + ) ), }; private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = @@ -1502,21 +1532,19 @@ private static final class GeneratedTusManagedUploadRuntimeCase { GeneratedTusManagedUploadRuntimeCase( GeneratedTusManagedUploadRuntimeProfile profile, GeneratedTusManagedUploadRuntimeCapabilities runtimeCapabilities, - GeneratedTusManagedUploadTransport transport, + GeneratedTusManagedUploadRuntimePlan runtimePlan, GeneratedTusManagedUploadOutcomeExpectations outcomeExpectations, GeneratedTusManagedUploadExecution execution, GeneratedTusManagedUploadStateExpectations stateExpectations, - GeneratedTusManagedUploadRetryPlan retryPlan, - GeneratedTusManagedUploadInput input, - GeneratedTusManagedUploadAttempt[] attempts) { + GeneratedTusManagedUploadWorkload workload) { this.scenarioId = profile.scenarioId; this.copySourceToOwnedStorage = runtimeCapabilities.copySourceToOwnedStorage; this.useDurableOsScheduler = runtimeCapabilities.useDurableOsScheduler; this.useFilesystemStateBackend = runtimeCapabilities.useFilesystemStateBackend; this.usePlatformKeyValueStateBackend = runtimeCapabilities.usePlatformKeyValueStateBackend; - this.initialState = transport.initialState; - this.locationHeaderName = transport.locationHeaderName; + this.initialState = runtimePlan.initialState; + this.locationHeaderName = runtimePlan.locationHeaderName; this.expectDeferredNetworkResult = outcomeExpectations.expectDeferredNetworkResult; this.expectTerminalFailure = outcomeExpectations.expectTerminalFailure; this.expectTerminalResult = outcomeExpectations.expectTerminalResult; @@ -1532,11 +1560,11 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.expectInputSourceExists = stateExpectations.inputSourceExists; this.expectOwnedSourceExists = stateExpectations.ownedSourceExists; this.expectResumeUrlExists = stateExpectations.resumeUrlExists; - this.expectedStates = retryPlan.expectedStates; - this.retryDelays = retryPlan.retryDelays; + this.expectedStates = runtimePlan.expectedStates; + this.retryDelays = runtimePlan.retryDelays; this.offsetDiscoveryMethod = offsetDiscoveryMethod(); - this.input = input; - this.attempts = attempts; + this.input = workload.input; + this.attempts = workload.attempts; } } @@ -1584,13 +1612,21 @@ private static final class GeneratedTusManagedUploadRuntimeCapabilities { } } - private static final class GeneratedTusManagedUploadTransport { + private static final class GeneratedTusManagedUploadRuntimePlan { + final String[] expectedStates; final String initialState; final String locationHeaderName; + final int[] retryDelays; - GeneratedTusManagedUploadTransport(String initialState, String locationHeaderName) { + GeneratedTusManagedUploadRuntimePlan( + String locationHeaderName, + String initialState, + String[] expectedStates, + int[] retryDelays) { + this.expectedStates = expectedStates; this.initialState = initialState; this.locationHeaderName = locationHeaderName; + this.retryDelays = retryDelays; } } @@ -1605,19 +1641,61 @@ private static final class GeneratedTusManagedUploadExecution { final boolean sourceUnavailableBeforeProtocol; GeneratedTusManagedUploadExecution( + GeneratedTusManagedUploadTerminalExecution terminalExecution, + GeneratedTusManagedUploadSchedulingExecution schedulingExecution, + GeneratedTusManagedUploadSourceExecution sourceExecution) { + this.cleanupOwnedSourceAfterTerminalState = + terminalExecution.cleanupOwnedSourceAfterTerminalState; + this.deferBeforeProtocol = schedulingExecution.deferBeforeProtocol; + this.expectIoExceptionOnTerminalFailure = + terminalExecution.expectIoExceptionOnTerminalFailure; + this.expectProtocolExceptionOnTerminalFailure = + terminalExecution.expectProtocolExceptionOnTerminalFailure; + this.networkConstraintSatisfied = schedulingExecution.networkConstraintSatisfied; + this.prepareDurableSourceBeforeProtocol = + sourceExecution.prepareDurableSourceBeforeProtocol; + this.simulateMissingSourceBeforeDurableCopy = + sourceExecution.simulateMissingSourceBeforeDurableCopy; + this.sourceUnavailableBeforeProtocol = sourceExecution.sourceUnavailableBeforeProtocol; + } + } + + private static final class GeneratedTusManagedUploadTerminalExecution { + final boolean cleanupOwnedSourceAfterTerminalState; + final boolean expectIoExceptionOnTerminalFailure; + final boolean expectProtocolExceptionOnTerminalFailure; + + GeneratedTusManagedUploadTerminalExecution( boolean cleanupOwnedSourceAfterTerminalState, - boolean deferBeforeProtocol, boolean expectIoExceptionOnTerminalFailure, - boolean expectProtocolExceptionOnTerminalFailure, - boolean networkConstraintSatisfied, - boolean prepareDurableSourceBeforeProtocol, - boolean simulateMissingSourceBeforeDurableCopy, - boolean sourceUnavailableBeforeProtocol) { + boolean expectProtocolExceptionOnTerminalFailure) { this.cleanupOwnedSourceAfterTerminalState = cleanupOwnedSourceAfterTerminalState; - this.deferBeforeProtocol = deferBeforeProtocol; this.expectIoExceptionOnTerminalFailure = expectIoExceptionOnTerminalFailure; this.expectProtocolExceptionOnTerminalFailure = expectProtocolExceptionOnTerminalFailure; + } + } + + private static final class GeneratedTusManagedUploadSchedulingExecution { + final boolean deferBeforeProtocol; + final boolean networkConstraintSatisfied; + + GeneratedTusManagedUploadSchedulingExecution( + boolean deferBeforeProtocol, + boolean networkConstraintSatisfied) { + this.deferBeforeProtocol = deferBeforeProtocol; this.networkConstraintSatisfied = networkConstraintSatisfied; + } + } + + private static final class GeneratedTusManagedUploadSourceExecution { + final boolean prepareDurableSourceBeforeProtocol; + final boolean simulateMissingSourceBeforeDurableCopy; + final boolean sourceUnavailableBeforeProtocol; + + GeneratedTusManagedUploadSourceExecution( + boolean prepareDurableSourceBeforeProtocol, + boolean simulateMissingSourceBeforeDurableCopy, + boolean sourceUnavailableBeforeProtocol) { this.prepareDurableSourceBeforeProtocol = prepareDurableSourceBeforeProtocol; this.simulateMissingSourceBeforeDurableCopy = simulateMissingSourceBeforeDurableCopy; this.sourceUnavailableBeforeProtocol = sourceUnavailableBeforeProtocol; @@ -1639,16 +1717,6 @@ private static final class GeneratedTusManagedUploadStateExpectations { } } - private static final class GeneratedTusManagedUploadRetryPlan { - final String[] expectedStates; - final int[] retryDelays; - - GeneratedTusManagedUploadRetryPlan(String[] expectedStates, int[] retryDelays) { - this.expectedStates = expectedStates; - this.retryDelays = retryDelays; - } - } - private static final class GeneratedTusManagedUploadInput { final String content; final int chunkSize; @@ -1670,6 +1738,18 @@ private static final class GeneratedTusManagedUploadInput { } } + private static final class GeneratedTusManagedUploadWorkload { + final GeneratedTusManagedUploadAttempt[] attempts; + final GeneratedTusManagedUploadInput input; + + GeneratedTusManagedUploadWorkload( + GeneratedTusManagedUploadInput input, + GeneratedTusManagedUploadAttempt[] attempts) { + this.attempts = attempts; + this.input = input; + } + } + private static final class GeneratedTusManagedUploadAttempt { final int attemptIndex; final String stateAfterAttempt; From d54e52606f3bb41e6ccf44a6636970996c29b5b0 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Thu, 4 Jun 2026 22:37:58 +0200 Subject: [PATCH 51/63] Use generated TUS offset discovery method --- .../client/GeneratedTusProtocolContract.java | 14 ++++++++++++++ .../TestGeneratedTusManagedUploadRuntime.java | 9 +-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 6bd1301..7d0c66f 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -361,6 +361,10 @@ final class GeneratedTusProtocolContract { ), }; + static final String OFFSET_DISCOVERY_OPERATION_ID = + "getTusUploadOffset"; + static final String OFFSET_DISCOVERY_METHOD = operationMethod(OFFSET_DISCOVERY_OPERATION_ID); + static final GeneratedTusClientFeature[] CLIENT_FEATURES = new GeneratedTusClientFeature[] { new GeneratedTusClientFeature( new GeneratedTusClientFeatureConformance( @@ -1361,6 +1365,16 @@ final class GeneratedTusProtocolContract { private GeneratedTusProtocolContract() { } + private static String operationMethod(String operationId) { + for (GeneratedTusProtocolOperation operation : OPERATIONS) { + if (operationId.equals(operation.operationId)) { + return operation.method; + } + } + + throw new AssertionError("Missing generated operation " + operationId); + } + private static Map defaultRequestHeaders() { Map result = new LinkedHashMap(); result.put("Tus-Resumable", "1.0.0"); diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 02b2c96..038fc99 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -1077,14 +1077,7 @@ private void copyFile(File source, File destination) throws IOException { } private static String offsetDiscoveryMethod() { - for (GeneratedTusProtocolContract.GeneratedTusProtocolOperation operation - : GeneratedTusProtocolContract.OPERATIONS) { - if ("offset-discovery".equals(operation.role)) { - return operation.method; - } - } - - throw new AssertionError("Missing generated offset-discovery operation"); + return GeneratedTusProtocolContract.OFFSET_DISCOVERY_METHOD; } private static final class GeneratedTusAndroidScheduler { From f633ff0a68347b6c81a8d2a814ad01fb04a42fcb Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 5 Jun 2026 10:55:50 +0200 Subject: [PATCH 52/63] Regenerate exact Android TUS transport tests --- .../client/GeneratedTusProtocolContract.java | 2 +- .../TestGeneratedTusManagedUploadRuntime.java | 57 ++++--------------- 2 files changed, 12 insertions(+), 47 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index 7d0c66f..c99cd8b 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -1176,7 +1176,7 @@ final class GeneratedTusProtocolContract { ), }; - static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"absent-after-source-unavailable\",\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-while-deferred\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadSourceUnavailable\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"covered-by-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadSourceUnavailable\",\n \"summary\": \"Classify source disappearance before protocol requests as terminal without issuing a TUS request.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-while-deferred\",\n \"resumeUrl\": \"absent-while-deferred\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello later!\",\n \"fingerprint\": \"managed-network-constraint-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-network-constraint.txt\"\n },\n \"uploadPath\": \"managed-network-constraint\"\n },\n \"network\": {\n \"current\": \"metered-network\",\n \"decision\": \"defer-until-network-constraint-satisfied\",\n \"required\": \"unmetered-network\"\n },\n \"outcome\": {\n \"kind\": \"deferred\",\n \"reason\": \"network-constraint-unsatisfied\",\n \"state\": \"pending\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; + static final String MANAGED_UPLOAD_JSON = "{\n \"capabilities\": {\n \"cleanup\": {\n \"policies\": [\n \"absent-after-source-unavailable\",\n \"remove-owned-source-after-success\",\n \"remove-owned-source-after-cancel\",\n \"retain-owned-source-while-deferred\",\n \"retain-owned-source-after-permanent-failure\",\n \"retain-source-after-retryable-failure\",\n \"remove-managed-state-after-terminal-retention\"\n ]\n },\n \"failureClassification\": {\n \"permanentFailures\": [\n \"source-unavailable\",\n \"unretryable-protocol-error\",\n \"retry-policy-exhausted\"\n ],\n \"retryableFailures\": [\n \"retryable-protocol-error\",\n \"io-error\",\n \"network-unavailable\"\n ]\n },\n \"networkConstraints\": {\n \"options\": [\n \"any-network\",\n \"unmetered-network\"\n ]\n },\n \"retryPolicy\": {\n \"controls\": [\n \"max-attempts\",\n \"deadline\",\n \"progress-sensitive-budget\",\n \"unbounded-until-permanent-failure\"\n ],\n \"permanentFailure\": \"stop-without-retry\",\n \"progressReset\": \"reset-budget-after-accepted-offset-advances\"\n },\n \"scheduling\": {\n \"strategies\": [\n \"foreground-task\",\n \"process-lifetime-worker-pool\",\n \"durable-os-scheduler\"\n ]\n },\n \"sourceDurability\": {\n \"ownedCopyCleanup\": \"after-success-or-cancel\",\n \"strategies\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ]\n },\n \"stateReporting\": {\n \"states\": [\n \"pending\",\n \"running\",\n \"succeeded\",\n \"failed\"\n ],\n \"terminalRetention\": \"session-and-next-launch\",\n \"transientRetention\": \"until-terminal\"\n }\n },\n \"conformance\": {\n \"scenarioIds\": [\n \"managedUploadDurableRetry\",\n \"managedUploadPermanentFailure\",\n \"managedUploadRetryPolicyExhausted\",\n \"managedUploadSourceUnavailable\",\n \"managedUploadNetworkConstraint\"\n ],\n \"status\": \"covered-by-generated-scenario\"\n },\n \"description\": \"Submit upload work that can make sources durable, schedule/resume execution, retry, report state, and clean up while reusing the raw TUS protocol features underneath.\",\n \"featureId\": \"managedUpload\",\n \"flow\": [\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"accept-upload-submission\",\n \"summary\": \"Accept source, metadata, headers, endpoint, and retry/scheduling policy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"make-source-durable\",\n \"summary\": \"Keep the source readable according to the selected runtime durability strategy.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"schedule-upload-work\",\n \"summary\": \"Run upload work according to the runtime scheduler capability.\"\n },\n {\n \"featureId\": \"singleUploadLifecycle\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use the raw protocol upload lifecycle for each execution attempt.\"\n },\n {\n \"featureId\": \"retryOffsetRecovery\",\n \"kind\": \"protocol-feature\",\n \"summary\": \"Use protocol retry and offset recovery before classifying terminal failure.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"publish-upload-state\",\n \"summary\": \"Expose pending, running, succeeded, and failed state snapshots.\"\n },\n {\n \"kind\": \"managed-primitive\",\n \"primitive\": \"cleanup-managed-upload\",\n \"summary\": \"Remove owned sources and terminal state according to cleanup policy.\"\n }\n ],\n \"layer\": \"feature-over-protocol\",\n \"primitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"protocolPrimitives\": [\n \"store-resume-url\",\n \"resume-from-previous-upload\",\n \"recover-offset-after-error\",\n \"retry-with-backoff\",\n \"emit-progress\",\n \"emit-chunk-complete\",\n \"terminate-upload\"\n ],\n \"runtimeProfiles\": [\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\",\n \"transportProfileId\": \"java-http-url-connection\"\n },\n {\n \"networkConstraints\": [\n \"any-network\",\n \"unmetered-network\"\n ],\n \"runtime\": \"ios\",\n \"scheduler\": \"durable-os-scheduler\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"browser\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"web-storage\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\"\n ],\n \"stateBackend\": \"filesystem\",\n \"transportProfileId\": \"java-http-url-connection\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"node\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"sourceDurability\": [\n \"copy-to-owned-storage\",\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"filesystem\"\n },\n {\n \"networkConstraints\": [\n \"any-network\"\n ],\n \"runtime\": \"react-native\",\n \"scheduler\": \"foreground-task\",\n \"sourceDurability\": [\n \"reference-original-source\",\n \"memory-only\"\n ],\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"scenarios\": [\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"afterAcceptedOffset\": 7,\n \"kind\": \"io-error\",\n \"phase\": \"after-accepted-offset\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {\n \"Location\": \"https://tus.io/uploads/managed-durable-retry\"\n },\n \"statusCode\": 201\n },\n \"url\": \"endpoint\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"0\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"requests\": [\n {\n \"headers\": {},\n \"operationId\": \"getTusUploadOffset\",\n \"response\": {\n \"headers\": {\n \"Upload-Length\": \"14\",\n \"Upload-Offset\": \"7\"\n },\n \"statusCode\": 200\n },\n \"url\": \"upload\"\n },\n {\n \"bodySize\": 7,\n \"headers\": {\n \"Upload-Offset\": \"7\"\n },\n \"operationId\": \"patchTusUpload\",\n \"response\": {\n \"headers\": {\n \"Upload-Offset\": \"14\"\n },\n \"statusCode\": 204\n },\n \"url\": \"upload\"\n }\n ],\n \"stateAfterAttempt\": \"succeeded\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"remove-owned-source-after-success\",\n \"resumeUrl\": \"remove-after-success\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello managed!\",\n \"fingerprint\": \"managed-durable-retry-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed.txt\"\n },\n \"uploadPath\": \"managed-durable-retry\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"kind\": \"terminal\",\n \"state\": \"succeeded\"\n },\n \"retryDelays\": [\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"succeeded\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadDurableRetry\",\n \"summary\": \"Submit a durable source, survive scheduler/process interruption, resume by stored upload URL, and finish with cleanup.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"unretryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 400\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello failure!\",\n \"fingerprint\": \"managed-permanent-failure-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-permanent-failure.txt\"\n },\n \"uploadPath\": \"managed-permanent-failure\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"unretryable-protocol-error\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadPermanentFailure\",\n \"summary\": \"Classify unretryable protocol failures as terminal without further retry.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 1,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n },\n {\n \"attemptIndex\": 2,\n \"failure\": {\n \"kind\": \"retryable-protocol-error\",\n \"phase\": \"during-protocol-request\"\n },\n \"requests\": [\n {\n \"bodySize\": 0,\n \"headers\": {\n \"Upload-Length\": \"14\"\n },\n \"operationId\": \"createTusUpload\",\n \"response\": {\n \"headers\": {},\n \"statusCode\": 500\n },\n \"url\": \"endpoint\"\n }\n ],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-after-permanent-failure\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello retries!\",\n \"fingerprint\": \"managed-retry-exhausted-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-retry-exhausted.txt\"\n },\n \"uploadPath\": \"managed-retry-exhausted\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"retry-policy-exhausted\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [\n 0,\n 0\n ],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"run-protocol-upload\",\n \"apply-managed-retry-policy\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadRetryPolicyExhausted\",\n \"summary\": \"Retry transient protocol failures up to the managed retry budget and then classify the upload as terminally failed.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"java\",\n \"scheduler\": \"process-lifetime-worker-pool\",\n \"stateBackend\": \"filesystem\"\n },\n {\n \"attempts\": [\n {\n \"attemptIndex\": 0,\n \"failure\": {\n \"kind\": \"source-unavailable\",\n \"phase\": \"before-protocol-request\"\n },\n \"requests\": [],\n \"stateAfterAttempt\": \"failed\"\n }\n ],\n \"cleanup\": {\n \"ownedSource\": \"absent-after-source-unavailable\",\n \"resumeUrl\": \"absent-after-permanent-failure\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello missing!\",\n \"fingerprint\": \"managed-source-unavailable-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-source-unavailable.txt\"\n },\n \"uploadPath\": \"managed-source-unavailable\"\n },\n \"network\": {\n \"current\": \"unmetered-network\",\n \"decision\": \"start-upload-work\",\n \"required\": \"any-network\"\n },\n \"outcome\": {\n \"failure\": \"source-unavailable\",\n \"kind\": \"terminal\",\n \"state\": \"failed\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"missing-before-durable-copy\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\",\n \"running\",\n \"failed\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"classify-failure\",\n \"publish-upload-state\",\n \"cleanup-managed-upload\"\n ],\n \"scenarioId\": \"managedUploadSourceUnavailable\",\n \"summary\": \"Classify source disappearance before protocol requests as terminal without issuing a TUS request.\"\n },\n {\n \"proofs\": [\n {\n \"attempts\": [],\n \"cleanup\": {\n \"ownedSource\": \"retain-owned-source-while-deferred\",\n \"resumeUrl\": \"absent-while-deferred\"\n },\n \"input\": {\n \"chunkSize\": 7,\n \"content\": \"hello later!\",\n \"fingerprint\": \"managed-network-constraint-fingerprint\",\n \"metadata\": {\n \"filename\": \"managed-network-constraint.txt\"\n },\n \"uploadPath\": \"managed-network-constraint\"\n },\n \"network\": {\n \"current\": \"metered-network\",\n \"decision\": \"defer-until-network-constraint-satisfied\",\n \"required\": \"unmetered-network\"\n },\n \"outcome\": {\n \"kind\": \"deferred\",\n \"reason\": \"network-constraint-unsatisfied\",\n \"state\": \"pending\"\n },\n \"retryDelays\": [],\n \"sourceAvailability\": \"available\",\n \"sourceDurability\": \"copy-to-owned-storage\",\n \"states\": [\n \"pending\"\n ],\n \"runtime\": \"android\",\n \"scheduler\": \"durable-os-scheduler\",\n \"stateBackend\": \"platform-key-value-store\"\n }\n ],\n \"requiredPrimitives\": [\n \"accept-upload-submission\",\n \"make-source-durable\",\n \"schedule-upload-work\",\n \"publish-upload-state\"\n ],\n \"scenarioId\": \"managedUploadNetworkConstraint\",\n \"summary\": \"Honor network constraints before starting or resuming upload work.\"\n }\n ]\n}\n"; static final String[] MANAGED_UPLOAD_PRIMITIVES = new String[] { diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 038fc99..1138489 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -160,7 +160,7 @@ public class TestGeneratedTusManagedUploadRuntime { ) ), new GeneratedTusManagedUploadRequest( - "PATCH", + "POST", "upload", 7, 204, @@ -175,6 +175,10 @@ public class TestGeneratedTusManagedUploadRuntime { "Upload-Offset", "0" ), + new GeneratedTusManagedUploadHeader( + "X-HTTP-Method-Override", + "PATCH" + ), } ), new GeneratedTusManagedUploadHeaderSet( @@ -219,7 +223,7 @@ public class TestGeneratedTusManagedUploadRuntime { ) ), new GeneratedTusManagedUploadRequest( - "PATCH", + "POST", "upload", 7, 204, @@ -234,6 +238,10 @@ public class TestGeneratedTusManagedUploadRuntime { "Upload-Offset", "7" ), + new GeneratedTusManagedUploadHeader( + "X-HTTP-Method-Override", + "PATCH" + ), } ), new GeneratedTusManagedUploadHeaderSet( @@ -677,15 +685,6 @@ public class TestGeneratedTusManagedUploadRuntime { ) ), }; - private static final GeneratedTusMethodOverride[] METHOD_OVERRIDES = - new GeneratedTusMethodOverride[] { - new GeneratedTusMethodOverride( - "PATCH", - "POST", - "X-HTTP-Method-Override", - "PATCH" - ), - }; /** * Verifies Android managed uploads can persist state and resume through platform storage. @@ -1266,14 +1265,7 @@ private boolean headersMatch( private boolean methodMatches( GeneratedTusHttpRequest httpRequest, GeneratedTusManagedUploadRequest request) { - if (request.method.equals(httpRequest.method)) { - return true; - } - GeneratedTusMethodOverride methodOverride = methodOverrideFor(request.method); - return methodOverride != null - && methodOverride.method.equals(httpRequest.method) - && methodOverride.headerValue.equals( - headerValue(httpRequest.headers, methodOverride.headerName)); + return request.method.equals(httpRequest.method); } private String pathFor(GeneratedTusManagedUploadRequest request) throws IOException { @@ -1464,16 +1456,6 @@ private static String headerValue(Map> headers, String name return null; } - private static GeneratedTusMethodOverride methodOverrideFor(String originalMethod) { - for (GeneratedTusMethodOverride methodOverride : METHOD_OVERRIDES) { - if (methodOverride.originalMethod.equals(originalMethod)) { - return methodOverride; - } - } - - return null; - } - private static final class GeneratedTusHttpRequest { final String method; final String path; @@ -1841,21 +1823,4 @@ private static final class GeneratedTusManagedUploadMetadata { } } - private static final class GeneratedTusMethodOverride { - final String originalMethod; - final String method; - final String headerName; - final String headerValue; - - GeneratedTusMethodOverride( - String originalMethod, - String method, - String headerName, - String headerValue) { - this.originalMethod = originalMethod; - this.method = method; - this.headerName = headerName; - this.headerValue = headerValue; - } - } } From 7693e4b926f6cac26e2fc65f2d254176f345a264 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 00:22:17 +0200 Subject: [PATCH 53/63] Regenerate Android managed upload runtime --- .../TestGeneratedTusManagedUploadRuntime.java | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 1138489..8213040 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -97,6 +97,7 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadSourceExecution( true, false, + -1, false ) ), @@ -298,6 +299,7 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadSourceExecution( true, false, + -1, false ) ), @@ -406,6 +408,7 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadSourceExecution( true, false, + -1, false ) ), @@ -581,6 +584,7 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadSourceExecution( false, true, + 0, true ) ), @@ -658,6 +662,7 @@ public class TestGeneratedTusManagedUploadRuntime { new GeneratedTusManagedUploadSourceExecution( true, false, + -1, false ) ), @@ -921,7 +926,12 @@ private void prepareSourceBeforeProtocol( return; } if (testCase.simulateMissingSourceBeforeDurableCopy) { - GeneratedTusManagedUploadAttempt attempt = testCase.attempts[0]; + GeneratedTusManagedUploadAttempt attempt = testCase.sourcePreparationFailureAttempt; + if (attempt == null) { + throw new AssertionError( + testCase.scenarioId + + " is missing generated source preparation failure attempt"); + } if (source.exists() && !source.delete()) { throw new IOException("Could not remove generated input source " + source); } @@ -1503,6 +1513,7 @@ private static final class GeneratedTusManagedUploadRuntimeCase { final String offsetDiscoveryMethod; final GeneratedTusManagedUploadInput input; final GeneratedTusManagedUploadAttempt[] attempts; + final GeneratedTusManagedUploadAttempt sourcePreparationFailureAttempt; GeneratedTusManagedUploadRuntimeCase( GeneratedTusManagedUploadRuntimeProfile profile, @@ -1540,6 +1551,10 @@ private static final class GeneratedTusManagedUploadRuntimeCase { this.offsetDiscoveryMethod = offsetDiscoveryMethod(); this.input = workload.input; this.attempts = workload.attempts; + this.sourcePreparationFailureAttempt = + execution.sourcePreparationFailureAttemptIndex < 0 + ? null + : workload.attempts[execution.sourcePreparationFailureAttemptIndex]; } } @@ -1613,6 +1628,7 @@ private static final class GeneratedTusManagedUploadExecution { final boolean networkConstraintSatisfied; final boolean prepareDurableSourceBeforeProtocol; final boolean simulateMissingSourceBeforeDurableCopy; + final int sourcePreparationFailureAttemptIndex; final boolean sourceUnavailableBeforeProtocol; GeneratedTusManagedUploadExecution( @@ -1631,6 +1647,8 @@ private static final class GeneratedTusManagedUploadExecution { sourceExecution.prepareDurableSourceBeforeProtocol; this.simulateMissingSourceBeforeDurableCopy = sourceExecution.simulateMissingSourceBeforeDurableCopy; + this.sourcePreparationFailureAttemptIndex = + sourceExecution.sourcePreparationFailureAttemptIndex; this.sourceUnavailableBeforeProtocol = sourceExecution.sourceUnavailableBeforeProtocol; } } @@ -1665,14 +1683,17 @@ private static final class GeneratedTusManagedUploadSchedulingExecution { private static final class GeneratedTusManagedUploadSourceExecution { final boolean prepareDurableSourceBeforeProtocol; final boolean simulateMissingSourceBeforeDurableCopy; + final int sourcePreparationFailureAttemptIndex; final boolean sourceUnavailableBeforeProtocol; GeneratedTusManagedUploadSourceExecution( boolean prepareDurableSourceBeforeProtocol, boolean simulateMissingSourceBeforeDurableCopy, + int sourcePreparationFailureAttemptIndex, boolean sourceUnavailableBeforeProtocol) { this.prepareDurableSourceBeforeProtocol = prepareDurableSourceBeforeProtocol; this.simulateMissingSourceBeforeDurableCopy = simulateMissingSourceBeforeDurableCopy; + this.sourcePreparationFailureAttemptIndex = sourcePreparationFailureAttemptIndex; this.sourceUnavailableBeforeProtocol = sourceUnavailableBeforeProtocol; } } From f614c5d41e7b11599ecb51256744a4a0bcd7af5d Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 00:39:01 +0200 Subject: [PATCH 54/63] Regenerate TUS protocol response fixtures --- .../client/GeneratedTusProtocolContract.java | 45 +++++++++++++++++++ .../TestGeneratedTusManagedUploadRuntime.java | 6 +-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java index c99cd8b..af7a727 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java +++ b/tus-android-client/src/test/java/io/tus/android/client/GeneratedTusProtocolContract.java @@ -181,6 +181,21 @@ final class GeneratedTusProtocolContract { ), } ), + new GeneratedTusResponseContract( + 500, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), } ), new GeneratedTusProtocolOperation( @@ -301,6 +316,21 @@ final class GeneratedTusProtocolContract { ), } ), + new GeneratedTusResponseContract( + 500, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), } ), new GeneratedTusProtocolOperation( @@ -339,6 +369,21 @@ final class GeneratedTusProtocolContract { ), } ), + new GeneratedTusResponseContract( + 423, + "empty", + new GeneratedTusHeaderVariant[] { + new GeneratedTusHeaderVariant( + new GeneratedTusHeaderField[] { + new GeneratedTusHeaderField( + "Tus-Resumable", + "tus-resumable", + true + ), + } + ), + } + ), } ), new GeneratedTusProtocolOperation( diff --git a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java index 8213040..e5f4d82 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TestGeneratedTusManagedUploadRuntime.java @@ -462,7 +462,7 @@ public class TestGeneratedTusManagedUploadRuntime { } ), new GeneratedTusManagedUploadHeaderSet( - false, + true, new GeneratedTusManagedUploadHeader[0] ) ), @@ -499,7 +499,7 @@ public class TestGeneratedTusManagedUploadRuntime { } ), new GeneratedTusManagedUploadHeaderSet( - false, + true, new GeneratedTusManagedUploadHeader[0] ) ), @@ -536,7 +536,7 @@ public class TestGeneratedTusManagedUploadRuntime { } ), new GeneratedTusManagedUploadHeaderSet( - false, + true, new GeneratedTusManagedUploadHeader[0] ) ), From 39d68d28afa6e90efb7258f6ab576953e8a081e6 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 06:40:30 +0200 Subject: [PATCH 55/63] Add Android TUS resume devdock proof --- .../android/client/Api2DevdockScenario.java | 265 ++++++++++++++++++ ...Api2DevdockTusResumeUploadExampleTest.java | 147 ++++++++++ .../Api2DevdockTusUploadExampleTest.java | 245 ++-------------- 3 files changed, 435 insertions(+), 222 deletions(-) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java create mode 100644 tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusResumeUploadExampleTest.java diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java new file mode 100644 index 0000000..fc48457 --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java @@ -0,0 +1,265 @@ +package io.tus.android.client; + +import android.app.Activity; +import android.content.ContentProvider; +import android.content.ContentValues; +import android.content.pm.ProviderInfo; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.robolectric.shadows.ShadowContentResolver; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; + +final class Api2DevdockScenario { + private static final String PROVIDER_AUTHORITY = "io.tus.android.client.api2devdock"; + + private Api2DevdockScenario() { + } + + static TusAndroidUpload androidUpload( + Activity activity, + Uri uri, + JSONObject scenario, + JSONObject createResponse, + String fingerprint + ) throws IOException, JSONException { + final TusAndroidUpload upload = new TusAndroidUpload(uri, activity); + upload.setFingerprint(fingerprint); + upload.setMetadata(uploadMetadata( + scenario.getJSONObject("upload"), + scenario, + createResponse + )); + + return upload; + } + + static int fixedChunkSizeBytes(JSONObject uploadConfig) throws JSONException { + final JSONObject chunkSize = uploadConfig.getJSONObject("chunkSize"); + final String kind = chunkSize.getString("kind"); + if (!"fixed-bytes".equals(kind)) { + throw new IllegalArgumentException("unsupported chunk size kind " + kind); + } + + return chunkSize.getInt("bytes"); + } + + static JSONObject loadScenario(String scenarioPath) throws IOException, JSONException { + final byte[] contents = Files.readAllBytes(Paths.get(scenarioPath)); + return new JSONObject(new String(contents, StandardCharsets.UTF_8)); + } + + static Uri registerContentUri( + Activity activity, + byte[] content, + String sourceName + ) throws IOException { + final File source = new File(activity.getCacheDir(), sourceName); + Files.write(source.toPath(), content); + + final Uri uri = Uri.parse("content://" + PROVIDER_AUTHORITY + "/" + sourceName); + final Api2DevdockContentProvider provider = new Api2DevdockContentProvider(source); + final ProviderInfo providerInfo = new ProviderInfo(); + providerInfo.authority = PROVIDER_AUTHORITY; + provider.attachInfo(activity, providerInfo); + ShadowContentResolver.registerProviderInternal( + PROVIDER_AUTHORITY, + provider + ); + + return uri; + } + + static byte[] scenarioBytes(JSONObject uploadConfig) throws JSONException { + final JSONObject source = uploadConfig.getJSONObject("source"); + final String kind = source.getString("kind"); + if (!"bytes".equals(kind)) { + throw new IllegalArgumentException("unsupported source kind " + kind); + } + + final String encoding = source.getString("encoding"); + if (!"utf8".equals(encoding)) { + throw new IllegalArgumentException("unsupported source encoding " + encoding); + } + + return source.getString("value").getBytes(StandardCharsets.UTF_8); + } + + static String scenarioPath() { + final String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO"); + if (scenarioPath != null && !scenarioPath.isEmpty()) { + return scenarioPath; + } + + final String defaultPath = "tus-android-client/api2-scenario.json"; + if (Files.exists(Paths.get(defaultPath))) { + return defaultPath; + } + + return null; + } + + static URL tusUrl( + JSONObject uploadConfig, + JSONObject scenario, + JSONObject createResponse + ) throws JSONException, java.net.MalformedURLException { + return new URL(scalarString( + resolveValue(uploadConfig.getJSONObject("tusUrl"), scenario, createResponse) + )); + } + + static void writeResult(JSONObject result) throws IOException, JSONException { + final String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); + if (resultPath == null || resultPath.isEmpty()) { + return; + } + + Files.write( + Paths.get(resultPath), + (result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8) + ); + } + + private static Object readPath(Object value, JSONArray pathParts) throws JSONException { + Object current = value; + for (int index = 0; index < pathParts.length(); index++) { + final Object part = pathParts.get(index); + if (current instanceof JSONObject && part instanceof String) { + current = ((JSONObject) current).get((String) part); + continue; + } + + if (current instanceof JSONArray && part instanceof Number) { + current = ((JSONArray) current).get(((Number) part).intValue()); + continue; + } + + throw new IllegalArgumentException("cannot read scenario path part " + part); + } + + return current; + } + + private static Object resolveValue( + JSONObject valueSpec, + JSONObject scenario, + JSONObject createResponse + ) throws JSONException { + if (valueSpec.has("value")) { + return valueSpec.get("value"); + } + + final JSONObject source = valueSpec.getJSONObject("source"); + final String root = source.getString("root"); + final Object rootValue; + if ("scenario".equals(root)) { + rootValue = scenario; + } else if ("createResponse".equals(root)) { + rootValue = createResponse; + } else { + throw new IllegalArgumentException("unsupported scenario value root " + root); + } + + return readPath(rootValue, source.getJSONArray("path")); + } + + private static String scalarString(Object value) { + if (JSONObject.NULL.equals(value)) { + return "null"; + } + + return String.valueOf(value); + } + + private static Map uploadMetadata( + JSONObject uploadConfig, + JSONObject scenario, + JSONObject createResponse + ) throws JSONException { + final JSONArray fields = uploadConfig.getJSONArray("metadata"); + final Map metadata = new LinkedHashMap(); + for (int index = 0; index < fields.length(); index++) { + final JSONObject field = fields.getJSONObject(index); + metadata.put( + field.getString("name"), + scalarString(resolveValue( + field.getJSONObject("value"), + scenario, + createResponse + )) + ); + } + + return metadata; + } + + private static final class Api2DevdockContentProvider extends ContentProvider { + private final File source; + + Api2DevdockContentProvider(File source) { + this.source = source; + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query( + Uri uri, + String[] projection, + String selection, + String[] selectionArgs, + String sortOrder + ) { + final MatrixCursor cursor = new MatrixCursor( + new String[]{OpenableColumns.SIZE, OpenableColumns.DISPLAY_NAME} + ); + cursor.addRow(new Object[]{source.length(), source.getName()}); + + return cursor; + } + + @Override + public String getType(Uri uri) { + return "text/plain"; + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + return ParcelFileDescriptor.open(source, ParcelFileDescriptor.MODE_READ_ONLY); + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + throw new UnsupportedOperationException(); + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusResumeUploadExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusResumeUploadExampleTest.java new file mode 100644 index 0000000..f0e2d8c --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusResumeUploadExampleTest.java @@ -0,0 +1,147 @@ +package io.tus.android.client; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.net.Uri; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.ConscryptMode; + +import java.io.IOException; +import java.net.URL; + +import io.tus.java.client.FingerprintNotFoundException; +import io.tus.java.client.ProtocolException; +import io.tus.java.client.ResumingNotEnabledException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUploader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +@ConscryptMode(ConscryptMode.Mode.OFF) +public class Api2DevdockTusResumeUploadExampleTest { + @Test + public void resumesAndroidContentUriUploadFromStoredUrl() throws Exception { + System.setProperty("http.strictPostRedirect", "true"); + + final String scenarioPath = Api2DevdockScenario.scenarioPath(); + if (!isRequired()) { + Assume.assumeTrue( + "API2 devdock scenario is only required through the dedicated API2 QA task", + scenarioPath != null + ); + } + if (scenarioPath == null) { + throw new IllegalStateException("API2_SDK_EXAMPLE_SCENARIO must be set"); + } + + final JSONObject scenario = Api2DevdockScenario.loadScenario(scenarioPath); + final JSONObject createResponse = + scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final Activity activity = Robolectric.setupActivity(Activity.class); + final JSONObject result = uploadWithStoredResume(activity, scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + assertEquals(result.getString("firstUploadUrl"), result.getString("uploadUrl")); + } + + private static JSONObject uploadWithStoredResume( + Activity activity, + JSONObject scenario, + JSONObject createResponse + ) throws FingerprintNotFoundException, IOException, JSONException, ProtocolException, + ResumingNotEnabledException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final JSONObject resumeConfig = uploadConfig.getJSONObject("resume"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final Uri uri = Api2DevdockScenario.registerContentUri( + activity, + content, + "api2-devdock-resume-upload.txt" + ); + + final SharedPreferences preferences = activity.getSharedPreferences( + "api2-devdock-tus-resume-upload", + 0 + ); + assertTrue(preferences.edit().clear().commit()); + + final TusPreferencesURLStore store = new TusPreferencesURLStore(preferences); + final TusClient client = new TusClient(); + client.setUploadCreationURL(Api2DevdockScenario.tusUrl( + uploadConfig, + scenario, + createResponse + )); + client.enableResuming(store); + if (resumeConfig.getBoolean("removeFingerprintOnSuccess")) { + client.enableRemoveFingerprintOnSuccess(); + } + + final String fingerprint = resumeConfig.getString("fingerprint"); + final TusUploader firstUploader = client.createUpload(Api2DevdockScenario.androidUpload( + activity, + uri, + scenario, + createResponse, + fingerprint + )); + firstUploader.setChunkSize(Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig)); + final int firstAcceptedBytes = firstUploader.uploadChunk(); + assertEquals(resumeConfig.getInt("stopAfterAcceptedBytes"), firstAcceptedBytes); + assertEquals(resumeConfig.getInt("stopAfterAcceptedBytes"), firstUploader.getOffset()); + assertNotNull(firstUploader.getUploadURL()); + final String firstUploadUrl = firstUploader.getUploadURL().toString(); + firstUploader.finish(false); + + final URL storedUploadUrl = store.get(fingerprint); + assertNotNull(storedUploadUrl); + assertEquals(firstUploadUrl, storedUploadUrl.toString()); + final int previousUploadCount = preferences.getAll().size(); + assertEquals(resumeConfig.getInt("expectedPreviousUploadCount"), previousUploadCount); + + final TusUploader resumedUploader = client.resumeUpload(Api2DevdockScenario.androidUpload( + activity, + uri, + scenario, + createResponse, + fingerprint + )); + resumedUploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = resumedUploader.uploadChunk(); + } while (uploadedChunkSize > -1); + resumedUploader.finish(); + + assertEquals(content.length, resumedUploader.getOffset()); + assertNotNull(resumedUploader.getUploadURL()); + final String uploadUrl = resumedUploader.getUploadURL().toString(); + final int remainingPreviousUploadCount = preferences.getAll().size(); + assertEquals( + resumeConfig.getInt("expectedRemainingPreviousUploadCount"), + remainingPreviousUploadCount + ); + + final JSONObject result = new JSONObject(); + result.put("firstUploadUrl", firstUploadUrl); + result.put("previousUploadCount", previousUploadCount); + result.put("remainingPreviousUploadCount", remainingPreviousUploadCount); + result.put("uploadUrl", uploadUrl); + + return result; + } + + private static boolean isRequired() { + return "true".equals(System.getProperty("api2DevdockTusResumeUpload.required")); + } +} diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java index 61cf2fa..6079117 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadExampleTest.java @@ -1,17 +1,9 @@ package io.tus.android.client; import android.app.Activity; -import android.content.ContentProvider; -import android.content.ContentValues; import android.content.SharedPreferences; -import android.content.pm.ProviderInfo; -import android.database.Cursor; -import android.database.MatrixCursor; import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.provider.OpenableColumns; -import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.junit.Assume; @@ -20,17 +12,8 @@ import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.ConscryptMode; -import org.robolectric.shadows.ShadowContentResolver; -import java.io.File; -import java.io.FileNotFoundException; import java.io.IOException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.LinkedHashMap; -import java.util.Map; import io.tus.java.client.ProtocolException; import io.tus.java.client.TusClient; @@ -42,13 +25,11 @@ @RunWith(RobolectricTestRunner.class) @ConscryptMode(ConscryptMode.Mode.OFF) public class Api2DevdockTusUploadExampleTest { - private static final String PROVIDER_AUTHORITY = "io.tus.android.client.api2devdock"; - @Test public void uploadsAndroidContentUriToTransloaditAssembly() throws Exception { System.setProperty("http.strictPostRedirect", "true"); - final String scenarioPath = scenarioPath(); + final String scenarioPath = Api2DevdockScenario.scenarioPath(); if (!isRequired()) { Assume.assumeTrue( "API2 devdock scenario is only required through the dedicated API2 QA task", @@ -59,12 +40,14 @@ public void uploadsAndroidContentUriToTransloaditAssembly() throws Exception { throw new IllegalStateException("API2_SDK_EXAMPLE_SCENARIO must be set"); } - final JSONObject scenario = loadScenario(scenarioPath); + final JSONObject scenario = Api2DevdockScenario.loadScenario(scenarioPath); final JSONObject createResponse = scenario.getJSONObject("prepared").getJSONObject("createResponse"); final Activity activity = Robolectric.setupActivity(Activity.class); final String uploadUrl = uploadWithTus(activity, scenario, createResponse); - writeResult(uploadUrl); + final JSONObject result = new JSONObject(); + result.put("uploadUrl", uploadUrl); + Api2DevdockScenario.writeResult(result); assertNotNull(uploadUrl); } @@ -75,8 +58,12 @@ private static String uploadWithTus( JSONObject createResponse ) throws IOException, JSONException, ProtocolException { final JSONObject uploadConfig = scenario.getJSONObject("upload"); - final byte[] content = scenarioBytes(uploadConfig); - final Uri uri = registerContentUri(activity, content); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final Uri uri = Api2DevdockScenario.registerContentUri( + activity, + content, + "api2-devdock-upload.txt" + ); final SharedPreferences preferences = activity.getSharedPreferences( "api2-devdock-tus-upload", @@ -85,15 +72,21 @@ private static String uploadWithTus( preferences.edit().clear().commit(); final TusClient client = new TusClient(); - client.setUploadCreationURL(new URL(scalarString( - resolveValue(uploadConfig.getJSONObject("tusUrl"), scenario, createResponse) - ))); + client.setUploadCreationURL(Api2DevdockScenario.tusUrl( + uploadConfig, + scenario, + createResponse + )); client.enableResuming(new TusPreferencesURLStore(preferences)); client.enableRemoveFingerprintOnSuccess(); - final TusAndroidUpload upload = new TusAndroidUpload(uri, activity); - upload.setFingerprint(scenario.getString("scenarioId") + "-android-devdock-example"); - upload.setMetadata(uploadMetadata(uploadConfig, scenario, createResponse)); + final TusAndroidUpload upload = Api2DevdockScenario.androidUpload( + activity, + uri, + scenario, + createResponse, + scenario.getString("scenarioId") + "-android-devdock-example" + ); final TusUploader uploader = client.resumeOrCreateUpload(upload); uploader.setChunkSize(content.length); @@ -109,199 +102,7 @@ private static String uploadWithTus( return uploader.getUploadURL().toString(); } - private static Uri registerContentUri(Activity activity, byte[] content) throws IOException { - final File source = new File(activity.getCacheDir(), "api2-devdock-upload.txt"); - Files.write(source.toPath(), content); - - final Uri uri = Uri.parse("content://" + PROVIDER_AUTHORITY + "/api2-devdock-upload.txt"); - final Api2DevdockContentProvider provider = new Api2DevdockContentProvider(source); - final ProviderInfo providerInfo = new ProviderInfo(); - providerInfo.authority = PROVIDER_AUTHORITY; - provider.attachInfo(activity, providerInfo); - ShadowContentResolver.registerProviderInternal( - PROVIDER_AUTHORITY, - provider - ); - - return uri; - } - - private static JSONObject loadScenario(String scenarioPath) throws IOException, JSONException { - final byte[] contents = Files.readAllBytes(Paths.get(scenarioPath)); - return new JSONObject(new String(contents, StandardCharsets.UTF_8)); - } - - private static String scenarioPath() { - final String scenarioPath = System.getenv("API2_SDK_EXAMPLE_SCENARIO"); - if (scenarioPath != null && !scenarioPath.isEmpty()) { - return scenarioPath; - } - - final String defaultPath = "tus-android-client/api2-scenario.json"; - if (Files.exists(Paths.get(defaultPath))) { - return defaultPath; - } - - return null; - } - private static boolean isRequired() { return "true".equals(System.getProperty("api2DevdockTusUpload.required")); } - - private static void writeResult(String uploadUrl) throws IOException, JSONException { - final String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); - if (resultPath == null || resultPath.isEmpty()) { - return; - } - - final JSONObject result = new JSONObject(); - result.put("uploadUrl", uploadUrl); - Files.write( - Paths.get(resultPath), - (result.toString(2) + "\n").getBytes(StandardCharsets.UTF_8) - ); - } - - private static byte[] scenarioBytes(JSONObject uploadConfig) throws JSONException { - final JSONObject source = uploadConfig.getJSONObject("source"); - final String kind = source.getString("kind"); - if (!"bytes".equals(kind)) { - throw new IllegalArgumentException("unsupported source kind " + kind); - } - - final String encoding = source.getString("encoding"); - if (!"utf8".equals(encoding)) { - throw new IllegalArgumentException("unsupported source encoding " + encoding); - } - - return source.getString("value").getBytes(StandardCharsets.UTF_8); - } - - private static Map uploadMetadata( - JSONObject uploadConfig, - JSONObject scenario, - JSONObject createResponse - ) throws JSONException { - final JSONArray fields = uploadConfig.getJSONArray("metadata"); - final Map metadata = new LinkedHashMap(); - for (int index = 0; index < fields.length(); index++) { - final JSONObject field = fields.getJSONObject(index); - metadata.put( - field.getString("name"), - scalarString(resolveValue( - field.getJSONObject("value"), - scenario, - createResponse - )) - ); - } - - return metadata; - } - - private static Object resolveValue( - JSONObject valueSpec, - JSONObject scenario, - JSONObject createResponse - ) throws JSONException { - if (valueSpec.has("value")) { - return valueSpec.get("value"); - } - - final JSONObject source = valueSpec.getJSONObject("source"); - final String root = source.getString("root"); - final Object rootValue; - if ("scenario".equals(root)) { - rootValue = scenario; - } else if ("createResponse".equals(root)) { - rootValue = createResponse; - } else { - throw new IllegalArgumentException("unsupported scenario value root " + root); - } - - return readPath(rootValue, source.getJSONArray("path")); - } - - private static Object readPath(Object value, JSONArray pathParts) throws JSONException { - Object current = value; - for (int index = 0; index < pathParts.length(); index++) { - final Object part = pathParts.get(index); - if (current instanceof JSONObject && part instanceof String) { - current = ((JSONObject) current).get((String) part); - continue; - } - - if (current instanceof JSONArray && part instanceof Number) { - current = ((JSONArray) current).get(((Number) part).intValue()); - continue; - } - - throw new IllegalArgumentException("cannot read scenario path part " + part); - } - - return current; - } - - private static String scalarString(Object value) { - if (JSONObject.NULL.equals(value)) { - return "null"; - } - - return String.valueOf(value); - } - - private static final class Api2DevdockContentProvider extends ContentProvider { - private final File source; - - Api2DevdockContentProvider(File source) { - this.source = source; - } - - @Override - public boolean onCreate() { - return true; - } - - @Override - public Cursor query( - Uri uri, - String[] projection, - String selection, - String[] selectionArgs, - String sortOrder - ) { - final MatrixCursor cursor = new MatrixCursor( - new String[]{OpenableColumns.SIZE, OpenableColumns.DISPLAY_NAME} - ); - cursor.addRow(new Object[]{source.length(), source.getName()}); - - return cursor; - } - - @Override - public String getType(Uri uri) { - return "text/plain"; - } - - @Override - public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { - return ParcelFileDescriptor.open(source, ParcelFileDescriptor.MODE_READ_ONLY); - } - - @Override - public Uri insert(Uri uri, ContentValues values) { - throw new UnsupportedOperationException(); - } - - @Override - public int delete(Uri uri, String selection, String[] selectionArgs) { - throw new UnsupportedOperationException(); - } - - @Override - public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { - throw new UnsupportedOperationException(); - } - } } From f3045b58588e7f459ba826480e3ef647b697812c Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 10:40:45 +0200 Subject: [PATCH 56/63] Add API2 upload callback proof --- settings.gradle | 5 + .../android/client/Api2DevdockScenario.java | 179 +++++++++++++++++ ...2DevdockTusUploadCallbacksExampleTest.java | 185 ++++++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadCallbacksExampleTest.java diff --git a/settings.gradle b/settings.gradle index 9aae33e..78339b5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,6 @@ include ':example', ':tus-android-client' + +def siblingTusJavaClient = file('../tus-java-client') +if (siblingTusJavaClient.exists()) { + includeBuild(siblingTusJavaClient) +} diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java index fc48457..ed8e7e2 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java @@ -22,7 +22,9 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; final class Api2DevdockScenario { @@ -31,6 +33,44 @@ final class Api2DevdockScenario { private Api2DevdockScenario() { } + static final class UploadCallbackEventKinds { + final String chunkComplete; + final String progress; + final String sourceClose; + final String success; + final String uploadUrlAvailable; + + UploadCallbackEventKinds(JSONObject eventKinds) throws JSONException { + chunkComplete = eventKinds.getString("chunkComplete"); + progress = eventKinds.getString("progress"); + sourceClose = eventKinds.getString("sourceClose"); + success = eventKinds.getString("success"); + uploadUrlAvailable = eventKinds.getString("uploadUrlAvailable"); + } + } + + static final class UploadCallbacksPlan { + final List allowedExtraEventKeyPrefixes; + final List> eventKeyAlternativeGroups; + final UploadCallbackEventKinds eventKinds; + final String eventKeyPartSeparator; + final List eventKeys; + final String eventPolicyMatching; + + UploadCallbacksPlan(JSONObject uploadCallbacks) throws JSONException { + allowedExtraEventKeyPrefixes = stringList( + uploadCallbacks.getJSONArray("allowedExtraEventKeyPrefixes") + ); + eventKeyAlternativeGroups = stringListList( + uploadCallbacks.getJSONArray("eventKeyAlternativeGroups") + ); + eventKinds = new UploadCallbackEventKinds(uploadCallbacks.getJSONObject("eventKinds")); + eventKeyPartSeparator = uploadCallbacks.getString("eventKeyPartSeparator"); + eventKeys = stringList(uploadCallbacks.getJSONArray("eventKeys")); + eventPolicyMatching = uploadCallbacks.getString("eventPolicyMatching"); + } + } + static TusAndroidUpload androidUpload( Activity activity, Uri uri, @@ -59,6 +99,65 @@ static int fixedChunkSizeBytes(JSONObject uploadConfig) throws JSONException { return chunkSize.getInt("bytes"); } + static List matchUploadCallbackEventKeys( + UploadCallbacksPlan plan, + List actual + ) { + if (!"exact".equals(plan.eventPolicyMatching) + && !"exact-except-allowed-extra-events".equals(plan.eventPolicyMatching)) { + throw new IllegalArgumentException( + "unsupported upload callback event policy " + plan.eventPolicyMatching + ); + } + + final List matched = new ArrayList(); + int expectedIndex = 0; + for (String event : actual) { + if (expectedIndex < plan.eventKeys.size() + && uploadCallbackEventMatchesExpected(plan, expectedIndex, event)) { + matched.add(plan.eventKeys.get(expectedIndex)); + expectedIndex += 1; + continue; + } + + if ("exact-except-allowed-extra-events".equals(plan.eventPolicyMatching) + && hasAllowedUploadCallbackExtraEventPrefix(plan, event)) { + continue; + } + + throw new IllegalStateException( + "unexpected upload callback event " + + event + + " at expected index " + + expectedIndex + + "; expected " + + plan.eventKeys + + ", actual " + + actual + ); + } + + if (expectedIndex != plan.eventKeys.size()) { + throw new IllegalStateException( + "missing upload callback events after index " + + expectedIndex + + "; expected " + + plan.eventKeys + + ", actual " + + actual + ); + } + + return matched; + } + + static void requireFullFileChunkSize(JSONObject uploadConfig) throws JSONException { + final Object chunkSize = uploadConfig.get("chunkSize"); + if (!"full-file".equals(chunkSize)) { + throw new IllegalArgumentException("unsupported chunk size policy " + chunkSize); + } + } + static JSONObject loadScenario(String scenarioPath) throws IOException, JSONException { final byte[] contents = Files.readAllBytes(Paths.get(scenarioPath)); return new JSONObject(new String(contents, StandardCharsets.UTF_8)); @@ -124,6 +223,32 @@ static URL tusUrl( )); } + static UploadCallbacksPlan uploadCallbacks(JSONObject scenario) throws JSONException { + return new UploadCallbacksPlan( + scenario.getJSONObject("upload").getJSONObject("uploadCallbacks") + ); + } + + static String uploadCallbackEventKey(UploadCallbacksPlan plan, String... parts) { + final StringBuilder key = new StringBuilder(); + for (int index = 0; index < parts.length; index++) { + if (index > 0) { + key.append(plan.eventKeyPartSeparator); + } + key.append(parts[index]); + } + + return key.toString(); + } + + static String uploadCallbackEventKeyNumber(long value) { + return Long.toString(value); + } + + static String uploadCallbackEventKeyTotal(long value) { + return scalarString(value); + } + static void writeResult(JSONObject result) throws IOException, JSONException { final String resultPath = System.getenv("API2_SDK_EXAMPLE_RESULT"); if (resultPath == null || resultPath.isEmpty()) { @@ -209,6 +334,60 @@ private static Map uploadMetadata( return metadata; } + private static boolean hasAllowedUploadCallbackExtraEventPrefix( + UploadCallbacksPlan plan, + String event + ) { + for (String prefix : plan.allowedExtraEventKeyPrefixes) { + if (event.startsWith(prefix)) { + return true; + } + } + + return false; + } + + private static List stringList(JSONArray values) throws JSONException { + final List result = new ArrayList(); + for (int index = 0; index < values.length(); index++) { + result.add(values.getString(index)); + } + + return result; + } + + private static List> stringListList(JSONArray values) throws JSONException { + final List> result = new ArrayList>(); + for (int index = 0; index < values.length(); index++) { + result.add(stringList(values.getJSONArray(index))); + } + + return result; + } + + private static boolean uploadCallbackEventMatchesExpected( + UploadCallbacksPlan plan, + int expectedIndex, + String event + ) { + if (plan.eventKeys.get(expectedIndex).equals(event)) { + return true; + } + + if (expectedIndex >= plan.eventKeyAlternativeGroups.size()) { + return false; + } + + final List alternatives = plan.eventKeyAlternativeGroups.get(expectedIndex); + for (String alternative : alternatives) { + if (alternative.equals(event)) { + return true; + } + } + + return false; + } + private static final class Api2DevdockContentProvider extends ContentProvider { private final File source; diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadCallbacksExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadCallbacksExampleTest.java new file mode 100644 index 0000000..11ed16a --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadCallbacksExampleTest.java @@ -0,0 +1,185 @@ +package io.tus.android.client; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.net.Uri; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.ConscryptMode; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUploader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +@ConscryptMode(ConscryptMode.Mode.OFF) +public class Api2DevdockTusUploadCallbacksExampleTest { + @Test + public void observesAndroidTusUploadCallbacks() throws Exception { + System.setProperty("http.strictPostRedirect", "true"); + + final String scenarioPath = Api2DevdockScenario.scenarioPath(); + if (!isRequired()) { + Assume.assumeTrue( + "API2 devdock scenario is only required through the dedicated API2 QA task", + scenarioPath != null + ); + } + if (scenarioPath == null) { + throw new IllegalStateException("API2_SDK_EXAMPLE_SCENARIO must be set"); + } + + final JSONObject scenario = Api2DevdockScenario.loadScenario(scenarioPath); + final JSONObject createResponse = + scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final Activity activity = Robolectric.setupActivity(Activity.class); + final JSONObject result = uploadWithCallbacks(activity, scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + assertNotNull(result.getString("uploadUrl")); + } + + private static JSONObject uploadWithCallbacks( + Activity activity, + JSONObject scenario, + JSONObject createResponse + ) throws IOException, JSONException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final Api2DevdockScenario.UploadCallbacksPlan callbacks = + Api2DevdockScenario.uploadCallbacks(scenario); + final List events = new ArrayList(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final Uri uri = Api2DevdockScenario.registerContentUri( + activity, + content, + "api2-devdock-upload-callbacks.txt" + ); + + final SharedPreferences preferences = activity.getSharedPreferences( + "api2-devdock-tus-upload-callbacks", + 0 + ); + assertTrue(preferences.edit().clear().commit()); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(Api2DevdockScenario.tusUrl( + uploadConfig, + scenario, + createResponse + )); + client.enableResuming(new TusPreferencesURLStore(preferences)); + + final TusAndroidUpload upload = Api2DevdockScenario.androidUpload( + activity, + uri, + scenario, + createResponse, + scenario.getString("scenarioId") + "-android-upload-callbacks" + ); + upload.setInputStream(new EventRecordingByteArrayInputStream(content, callbacks, events)); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.uploadUrlAvailable + )); + uploader.setChunkSize(content.length); + uploader.setProgressListener(new TusUploader.ProgressListener() { + @Override + public void onProgress(long bytesSent, long bytesTotal) { + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.progress, + Api2DevdockScenario.uploadCallbackEventKeyNumber(bytesSent), + Api2DevdockScenario.uploadCallbackEventKeyTotal(bytesTotal) + )); + } + }); + uploader.setChunkCompleteListener(new TusUploader.ChunkCompleteListener() { + @Override + public void onChunkComplete(long chunkSize, long bytesAccepted, long bytesTotal) { + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.chunkComplete, + Api2DevdockScenario.uploadCallbackEventKeyNumber(chunkSize), + Api2DevdockScenario.uploadCallbackEventKeyNumber(bytesAccepted), + Api2DevdockScenario.uploadCallbackEventKeyTotal(bytesTotal) + )); + } + }); + + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + + assertEquals(content.length, uploader.getOffset()); + assertNotNull(uploader.getUploadURL()); + + uploader.finish(false); + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.success + )); + uploader.finish(); + + final List matchedEvents = + Api2DevdockScenario.matchUploadCallbackEventKeys(callbacks, events); + assertEquals(callbacks.eventKeys, matchedEvents); + + return new JSONObject() + .put("eventKeys", new JSONArray(matchedEvents)) + .put("rawEventKeys", new JSONArray(events)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static boolean isRequired() { + return "true".equals(System.getProperty("api2DevdockTusUploadCallbacks.required")); + } + + private static final class EventRecordingByteArrayInputStream extends ByteArrayInputStream { + private final Api2DevdockScenario.UploadCallbacksPlan callbacks; + private boolean closed; + private final List events; + + EventRecordingByteArrayInputStream( + byte[] content, + Api2DevdockScenario.UploadCallbacksPlan callbacks, + List events + ) { + super(content); + this.callbacks = callbacks; + this.events = events; + } + + @Override + public void close() throws IOException { + if (!closed) { + events.add(Api2DevdockScenario.uploadCallbackEventKey( + callbacks, + callbacks.eventKinds.sourceClose + )); + closed = true; + } + super.close(); + } + } +} From 20cefebd11d10924bfe422cae5e1a3d60f170e12 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 16:33:47 +0200 Subject: [PATCH 57/63] Add API2 upload body headers proof --- .github/workflows/CI.yml | 6 + .gitignore | 1 + settings.gradle | 9 +- .../android/client/Api2DevdockScenario.java | 34 ++++ ...evdockTusUploadBodyHeadersExampleTest.java | 167 ++++++++++++++++++ 5 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadBodyHeadersExampleTest.java diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 74472b2..332d58b 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -22,6 +22,12 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Checkout tus-java-client companion branch + uses: actions/checkout@v6 + with: + repository: tus/tus-java-client + ref: tus-gen + path: tus-java-client - name: set up JDK ${{ matrix.java }} uses: actions/setup-java@v5 with: diff --git a/.gitignore b/.gitignore index 9d28fe9..4854a52 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ /.idea/libraries .DS_Store /build +/tus-java-client *.iml .idea/ diff --git a/settings.gradle b/settings.gradle index 78339b5..5ab73d3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,9 @@ include ':example', ':tus-android-client' -def siblingTusJavaClient = file('../tus-java-client') -if (siblingTusJavaClient.exists()) { - includeBuild(siblingTusJavaClient) +def companionTusJavaClient = [ + file('../tus-java-client'), + file('tus-java-client') +].find { it.exists() } +if (companionTusJavaClient != null) { + includeBuild(companionTusJavaClient) } diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java index ed8e7e2..f632dd3 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java @@ -229,6 +229,25 @@ static UploadCallbacksPlan uploadCallbacks(JSONObject scenario) throws JSONExcep ); } + static Map> uploadBodyHeadersByMethod( + JSONObject uploadConfig + ) throws JSONException { + final JSONObject bodyHeadersByMethod = uploadConfig.getJSONObject("bodyHeadersByMethod"); + final Map> result = + new LinkedHashMap>(); + final JSONArray methods = bodyHeadersByMethod.names(); + if (methods == null) { + return result; + } + + for (int index = 0; index < methods.length(); index++) { + final String method = methods.getString(index); + result.put(method, stringMap(bodyHeadersByMethod.getJSONObject(method))); + } + + return result; + } + static String uploadCallbackEventKey(UploadCallbacksPlan plan, String... parts) { final StringBuilder key = new StringBuilder(); for (int index = 0; index < parts.length; index++) { @@ -365,6 +384,21 @@ private static List> stringListList(JSONArray values) throws JSONEx return result; } + private static Map stringMap(JSONObject values) throws JSONException { + final Map result = new LinkedHashMap(); + final JSONArray names = values.names(); + if (names == null) { + return result; + } + + for (int index = 0; index < names.length(); index++) { + final String name = names.getString(index); + result.put(name, values.getString(name)); + } + + return result; + } + private static boolean uploadCallbackEventMatchesExpected( UploadCallbacksPlan plan, int expectedIndex, diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadBodyHeadersExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadBodyHeadersExampleTest.java new file mode 100644 index 0000000..6a5a14e --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusUploadBodyHeadersExampleTest.java @@ -0,0 +1,167 @@ +package io.tus.android.client; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.net.Uri; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.ConscryptMode; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusUploader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +@ConscryptMode(ConscryptMode.Mode.OFF) +public class Api2DevdockTusUploadBodyHeadersExampleTest { + @Test + public void observesAndroidTusUploadBodyHeaders() throws Exception { + System.setProperty("http.strictPostRedirect", "true"); + + final String scenarioPath = Api2DevdockScenario.scenarioPath(); + if (!isRequired()) { + Assume.assumeTrue( + "API2 devdock scenario is only required through the dedicated API2 QA task", + scenarioPath != null + ); + } + if (scenarioPath == null) { + throw new IllegalStateException("API2_SDK_EXAMPLE_SCENARIO must be set"); + } + + final JSONObject scenario = Api2DevdockScenario.loadScenario(scenarioPath); + final JSONObject createResponse = + scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final Activity activity = Robolectric.setupActivity(Activity.class); + final JSONObject result = uploadWithBodyHeaders(activity, scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + assertNotNull(result.getString("uploadUrl")); + } + + private static JSONObject uploadWithBodyHeaders( + Activity activity, + JSONObject scenario, + JSONObject createResponse + ) throws IOException, JSONException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final Map> expectedHeadersByMethod = + Api2DevdockScenario.uploadBodyHeadersByMethod(uploadConfig); + final Map> bodyHeadersByMethod = + new LinkedHashMap>(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final Uri uri = Api2DevdockScenario.registerContentUri( + activity, + content, + "api2-devdock-upload-body-headers.txt" + ); + + final SharedPreferences preferences = activity.getSharedPreferences( + "api2-devdock-tus-upload-body-headers", + 0 + ); + assertTrue(preferences.edit().clear().commit()); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(Api2DevdockScenario.tusUrl( + uploadConfig, + scenario, + createResponse + )); + client.enableResuming(new TusPreferencesURLStore(preferences)); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + final Map expectedHeaders = + expectedHeadersByMethod.get(context.getMethod()); + if (expectedHeaders == null) { + return; + } + + bodyHeadersByMethod.put( + context.getMethod(), + observedBodyHeaders( + context.getConnection(), + context.getMethod(), + expectedHeaders + ) + ); + } + }, + null + )); + + final TusAndroidUpload upload = Api2DevdockScenario.androidUpload( + activity, + uri, + scenario, + createResponse, + scenario.getString("scenarioId") + "-android-upload-body-headers" + ); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + + assertEquals(content.length, uploader.getOffset()); + assertNotNull(uploader.getUploadURL()); + for (String method : expectedHeadersByMethod.keySet()) { + if (!bodyHeadersByMethod.containsKey(method)) { + throw new IllegalStateException( + "upload body headers did not observe " + method + " request" + ); + } + } + + return new JSONObject() + .put("bodyHeadersByMethod", new JSONObject(bodyHeadersByMethod)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static Map observedBodyHeaders( + HttpURLConnection connection, + String method, + Map expectedHeaders + ) { + final Map headers = new LinkedHashMap(); + for (Map.Entry entry : expectedHeaders.entrySet()) { + final String value = connection.getRequestProperty(entry.getKey()); + if (value == null) { + throw new IllegalStateException( + "upload body headers did not observe " + entry.getKey() + " on " + method + ); + } + + headers.put(entry.getKey(), value); + } + + return headers; + } + + private static boolean isRequired() { + return "true".equals(System.getProperty("api2DevdockTusUploadBodyHeaders.required")); + } +} From d4924e393a009c90aa477b2c9276cba8fdda4596 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 16:45:37 +0200 Subject: [PATCH 58/63] Add API2 request header proofs --- .../android/client/Api2DevdockScenario.java | 12 ++ ...ockTusCustomRequestHeadersExampleTest.java | 156 +++++++++++++++++ ...DevdockTusRequestIdHeadersExampleTest.java | 162 ++++++++++++++++++ 3 files changed, 330 insertions(+) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusCustomRequestHeadersExampleTest.java create mode 100644 tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusRequestIdHeadersExampleTest.java diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java index f632dd3..40c83e1 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java @@ -229,6 +229,10 @@ static UploadCallbacksPlan uploadCallbacks(JSONObject scenario) throws JSONExcep ); } + static boolean uploadAddRequestId(JSONObject uploadConfig) throws JSONException { + return uploadConfig.getBoolean("addRequestId"); + } + static Map> uploadBodyHeadersByMethod( JSONObject uploadConfig ) throws JSONException { @@ -248,6 +252,14 @@ static Map> uploadBodyHeadersByMethod( return result; } + static Map uploadHeaders(JSONObject uploadConfig) throws JSONException { + return stringMap(uploadConfig.getJSONObject("headers")); + } + + static String uploadRequestIdHeaderName(JSONObject uploadConfig) throws JSONException { + return uploadConfig.getString("requestIdHeaderName"); + } + static String uploadCallbackEventKey(UploadCallbacksPlan plan, String... parts) { final StringBuilder key = new StringBuilder(); for (int index = 0; index < parts.length; index++) { diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusCustomRequestHeadersExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusCustomRequestHeadersExampleTest.java new file mode 100644 index 0000000..051593c --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusCustomRequestHeadersExampleTest.java @@ -0,0 +1,156 @@ +package io.tus.android.client; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.net.Uri; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.ConscryptMode; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusUploader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +@ConscryptMode(ConscryptMode.Mode.OFF) +public class Api2DevdockTusCustomRequestHeadersExampleTest { + @Test + public void observesAndroidTusCustomRequestHeaders() throws Exception { + System.setProperty("http.strictPostRedirect", "true"); + + final String scenarioPath = Api2DevdockScenario.scenarioPath(); + if (!isRequired()) { + Assume.assumeTrue( + "API2 devdock scenario is only required through the dedicated API2 QA task", + scenarioPath != null + ); + } + if (scenarioPath == null) { + throw new IllegalStateException("API2_SDK_EXAMPLE_SCENARIO must be set"); + } + + final JSONObject scenario = Api2DevdockScenario.loadScenario(scenarioPath); + final JSONObject createResponse = + scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final Activity activity = Robolectric.setupActivity(Activity.class); + final JSONObject result = uploadWithCustomHeaders(activity, scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + assertNotNull(result.getString("uploadUrl")); + } + + private static JSONObject uploadWithCustomHeaders( + Activity activity, + JSONObject scenario, + JSONObject createResponse + ) throws IOException, JSONException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final Map expectedHeaders = + Api2DevdockScenario.uploadHeaders(uploadConfig); + final Map> headersByMethod = + new LinkedHashMap>(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final Uri uri = Api2DevdockScenario.registerContentUri( + activity, + content, + "api2-devdock-custom-request-headers.txt" + ); + + final SharedPreferences preferences = activity.getSharedPreferences( + "api2-devdock-tus-custom-request-headers", + 0 + ); + assertTrue(preferences.edit().clear().commit()); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(Api2DevdockScenario.tusUrl( + uploadConfig, + scenario, + createResponse + )); + client.enableResuming(new TusPreferencesURLStore(preferences)); + client.setHeaders(expectedHeaders); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + if ("POST".equals(context.getMethod()) + || "PATCH".equals(context.getMethod())) { + headersByMethod.put( + context.getMethod(), + observedCustomHeaders( + context.getConnection(), + expectedHeaders + ) + ); + } + } + }, + null + )); + + final TusAndroidUpload upload = Api2DevdockScenario.androidUpload( + activity, + uri, + scenario, + createResponse, + scenario.getString("scenarioId") + "-android-custom-request-headers" + ); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + + assertEquals(content.length, uploader.getOffset()); + assertNotNull(uploader.getUploadURL()); + + return new JSONObject() + .put("headersByMethod", new JSONObject(headersByMethod)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static Map observedCustomHeaders( + HttpURLConnection connection, + Map expectedHeaders + ) { + final Map headers = new LinkedHashMap(); + for (Map.Entry entry : expectedHeaders.entrySet()) { + final String value = connection.getRequestProperty(entry.getKey()); + if (value == null) { + throw new IllegalStateException( + "custom request headers did not observe " + entry.getKey() + ); + } + + headers.put(entry.getKey(), value); + } + + return headers; + } + + private static boolean isRequired() { + return "true".equals(System.getProperty("api2DevdockTusCustomRequestHeaders.required")); + } +} diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusRequestIdHeadersExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusRequestIdHeadersExampleTest.java new file mode 100644 index 0000000..038ea49 --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusRequestIdHeadersExampleTest.java @@ -0,0 +1,162 @@ +package io.tus.android.client; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.net.Uri; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.ConscryptMode; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusUploader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +@ConscryptMode(ConscryptMode.Mode.OFF) +public class Api2DevdockTusRequestIdHeadersExampleTest { + @Test + public void observesAndroidTusRequestIdHeaders() throws Exception { + System.setProperty("http.strictPostRedirect", "true"); + + final String scenarioPath = Api2DevdockScenario.scenarioPath(); + if (!isRequired()) { + Assume.assumeTrue( + "API2 devdock scenario is only required through the dedicated API2 QA task", + scenarioPath != null + ); + } + if (scenarioPath == null) { + throw new IllegalStateException("API2_SDK_EXAMPLE_SCENARIO must be set"); + } + + final JSONObject scenario = Api2DevdockScenario.loadScenario(scenarioPath); + final JSONObject createResponse = + scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final Activity activity = Robolectric.setupActivity(Activity.class); + final JSONObject result = uploadWithRequestIdHeaders(activity, scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + assertNotNull(result.getString("uploadUrl")); + } + + private static JSONObject uploadWithRequestIdHeaders( + Activity activity, + JSONObject scenario, + JSONObject createResponse + ) throws IOException, JSONException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final String requestIdHeaderName = + Api2DevdockScenario.uploadRequestIdHeaderName(uploadConfig); + final Map> headersByMethod = + new LinkedHashMap>(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final Uri uri = Api2DevdockScenario.registerContentUri( + activity, + content, + "api2-devdock-request-id-headers.txt" + ); + + final SharedPreferences preferences = activity.getSharedPreferences( + "api2-devdock-tus-request-id-headers", + 0 + ); + assertTrue(preferences.edit().clear().commit()); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(Api2DevdockScenario.tusUrl( + uploadConfig, + scenario, + createResponse + )); + client.enableResuming(new TusPreferencesURLStore(preferences)); + client.setHeaders(Api2DevdockScenario.uploadHeaders(uploadConfig)); + if (Api2DevdockScenario.uploadAddRequestId(uploadConfig)) { + client.enableRequestIdHeader(); + } + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + if ("POST".equals(context.getMethod()) + || "PATCH".equals(context.getMethod())) { + final Map headers = + new LinkedHashMap(); + headers.put( + requestIdHeaderName, + observedRequestIdHeader( + context.getConnection(), + context.getMethod(), + requestIdHeaderName + ) + ); + headersByMethod.put(context.getMethod(), headers); + } + } + }, + null + )); + + final TusAndroidUpload upload = Api2DevdockScenario.androidUpload( + activity, + uri, + scenario, + createResponse, + scenario.getString("scenarioId") + "-android-request-id-headers" + ); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + + assertEquals(content.length, uploader.getOffset()); + assertNotNull(uploader.getUploadURL()); + + return new JSONObject() + .put("headersByMethod", new JSONObject(headersByMethod)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static String observedRequestIdHeader( + HttpURLConnection connection, + String method, + String requestIdHeaderName + ) { + final String value = connection.getRequestProperty(requestIdHeaderName); + if (value == null || value.isEmpty()) { + throw new IllegalStateException( + "request ID headers did not observe " + + requestIdHeaderName + + " on " + + method + ); + } + + return value; + } + + private static boolean isRequired() { + return "true".equals(System.getProperty("api2DevdockTusRequestIdHeaders.required")); + } +} From 5a94b0821690a0b1912645b3e8597259828eadfa Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 17:16:58 +0200 Subject: [PATCH 59/63] Add Android TUS terminate upload proof --- .../android/client/Api2DevdockScenario.java | 20 +++ ...2DevdockTusTerminateUploadExampleTest.java | 166 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusTerminateUploadExampleTest.java diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java index 40c83e1..58f00ab 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java @@ -71,6 +71,22 @@ static final class UploadCallbacksPlan { } } + static final class TerminationPlan { + final int expectedVerificationStatus; + final String method; + final int minimumDeleteRequestCount; + final int stopAfterAcceptedBytes; + final String verificationMethod; + + TerminationPlan(JSONObject termination) throws JSONException { + expectedVerificationStatus = termination.getInt("expectedVerificationStatus"); + method = termination.getString("method"); + minimumDeleteRequestCount = termination.getInt("minimumDeleteRequestCount"); + stopAfterAcceptedBytes = termination.getInt("stopAfterAcceptedBytes"); + verificationMethod = termination.getString("verificationMethod"); + } + } + static TusAndroidUpload androidUpload( Activity activity, Uri uri, @@ -229,6 +245,10 @@ static UploadCallbacksPlan uploadCallbacks(JSONObject scenario) throws JSONExcep ); } + static TerminationPlan termination(JSONObject uploadConfig) throws JSONException { + return new TerminationPlan(uploadConfig.getJSONObject("termination")); + } + static boolean uploadAddRequestId(JSONObject uploadConfig) throws JSONException { return uploadConfig.getBoolean("addRequestId"); } diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusTerminateUploadExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusTerminateUploadExampleTest.java new file mode 100644 index 0000000..07957c4 --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusTerminateUploadExampleTest.java @@ -0,0 +1,166 @@ +package io.tus.android.client; + +import android.app.Activity; +import android.net.Uri; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.ConscryptMode; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusUploader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@RunWith(RobolectricTestRunner.class) +@ConscryptMode(ConscryptMode.Mode.OFF) +public class Api2DevdockTusTerminateUploadExampleTest { + @Test + public void terminatesAndroidContentUriUpload() throws Exception { + System.setProperty("http.strictPostRedirect", "true"); + + final String scenarioPath = Api2DevdockScenario.scenarioPath(); + if (!isRequired()) { + Assume.assumeTrue( + "API2 devdock scenario is only required through the dedicated API2 QA task", + scenarioPath != null + ); + } + if (scenarioPath == null) { + throw new IllegalStateException("API2_SDK_EXAMPLE_SCENARIO must be set"); + } + + final JSONObject scenario = Api2DevdockScenario.loadScenario(scenarioPath); + final JSONObject createResponse = + scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final Activity activity = Robolectric.setupActivity(Activity.class); + final JSONObject result = uploadAndTerminate(activity, scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + assertEquals(true, result.getBoolean("terminated")); + assertNotNull(result.getString("uploadUrl")); + } + + private static JSONObject uploadAndTerminate( + Activity activity, + JSONObject scenario, + JSONObject createResponse + ) throws IOException, JSONException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final Api2DevdockScenario.TerminationPlan termination = + Api2DevdockScenario.termination(uploadConfig); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + final List requestMethods = new ArrayList(); + + if (termination.stopAfterAcceptedBytes > content.length) { + throw new IllegalStateException( + "terminate upload stop-after bytes " + + termination.stopAfterAcceptedBytes + + " exceeds content length " + + content.length + ); + } + + final Uri uri = Api2DevdockScenario.registerContentUri( + activity, + content, + "api2-devdock-terminate-upload.txt" + ); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(Api2DevdockScenario.tusUrl( + uploadConfig, + scenario, + createResponse + )); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + requestMethods.add(context.getMethod()); + } + }, + null + )); + + final TusUploader uploader = client.createUpload(Api2DevdockScenario.androidUpload( + activity, + uri, + scenario, + createResponse, + scenario.getString("scenarioId") + "-android-terminate-upload" + )); + uploader.setChunkSize(chunkSize); + uploader.setRequestPayloadSize(termination.stopAfterAcceptedBytes); + final int uploadedChunkSize = uploader.uploadChunk(); + uploader.finish(); + + assertEquals(termination.stopAfterAcceptedBytes, uploadedChunkSize); + assertEquals(termination.stopAfterAcceptedBytes, uploader.getOffset()); + assertNotNull(uploader.getUploadURL()); + + final URL uploadUrl = uploader.getUploadURL(); + client.terminateUpload(uploadUrl).disconnect(); + final int verificationStatus = verifyTerminatedUpload( + client, + termination.verificationMethod, + uploadUrl + ); + assertEquals(termination.expectedVerificationStatus, verificationStatus); + + return new JSONObject() + .put("acceptedBytes", (int) uploader.getOffset()) + .put("deleteRequestCount", countMethod(requestMethods, termination.method)) + .put("requestMethods", new JSONArray(requestMethods)) + .put("terminated", true) + .put("uploadUrl", uploadUrl.toString()) + .put("verificationStatus", verificationStatus); + } + + private static int verifyTerminatedUpload( + TusClient client, + String method, + URL uploadUrl + ) throws IOException { + final HttpURLConnection connection = (HttpURLConnection) uploadUrl.openConnection(); + try { + connection.setRequestMethod(method); + client.prepareConnection(connection); + connection.connect(); + return connection.getResponseCode(); + } finally { + connection.disconnect(); + } + } + + private static int countMethod(List methods, String expectedMethod) { + int count = 0; + for (String method : methods) { + if (method.equals(expectedMethod)) { + count += 1; + } + } + + return count; + } + + private static boolean isRequired() { + return "true".equals(System.getProperty("api2DevdockTusTerminateUpload.required")); + } +} From 9dbda02629ad80fb850d1a4e742d9922c0b213dc Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Sun, 7 Jun 2026 18:03:57 +0200 Subject: [PATCH 60/63] Add Android TUS creation-with-upload proof --- ...vdockTusCreationWithUploadExampleTest.java | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusCreationWithUploadExampleTest.java diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusCreationWithUploadExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusCreationWithUploadExampleTest.java new file mode 100644 index 0000000..6400ccf --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusCreationWithUploadExampleTest.java @@ -0,0 +1,105 @@ +package io.tus.android.client; + +import android.app.Activity; +import android.net.Uri; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.ConscryptMode; + +import java.io.IOException; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUploader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@RunWith(RobolectricTestRunner.class) +@ConscryptMode(ConscryptMode.Mode.OFF) +public class Api2DevdockTusCreationWithUploadExampleTest { + @Test + public void createsAndroidContentUriUploadWithCreationData() throws Exception { + System.setProperty("http.strictPostRedirect", "true"); + + final String scenarioPath = Api2DevdockScenario.scenarioPath(); + if (!isRequired()) { + Assume.assumeTrue( + "API2 devdock scenario is only required through the dedicated API2 QA task", + scenarioPath != null + ); + } + if (scenarioPath == null) { + throw new IllegalStateException("API2_SDK_EXAMPLE_SCENARIO must be set"); + } + + final JSONObject scenario = Api2DevdockScenario.loadScenario(scenarioPath); + final JSONObject createResponse = + scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final Activity activity = Robolectric.setupActivity(Activity.class); + final JSONObject result = uploadWithCreationData(activity, scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + assertEquals( + Api2DevdockScenario.scenarioBytes(scenario.getJSONObject("upload")).length, + result.getInt("acceptedBytes") + ); + assertNotNull(result.getString("uploadUrl")); + } + + private static JSONObject uploadWithCreationData( + Activity activity, + JSONObject scenario, + JSONObject createResponse + ) throws IOException, JSONException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + if (!uploadConfig.getBoolean("uploadDataDuringCreation")) { + throw new IllegalStateException( + "creation-with-upload scenario must set uploadDataDuringCreation" + ); + } + + final Uri uri = Api2DevdockScenario.registerContentUri( + activity, + content, + "api2-devdock-creation-with-upload.txt" + ); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(Api2DevdockScenario.tusUrl( + uploadConfig, + scenario, + createResponse + )); + + final TusAndroidUpload upload = Api2DevdockScenario.androidUpload( + activity, + uri, + scenario, + createResponse, + scenario.getString("scenarioId") + "-android-creation-with-upload" + ); + + final TusUploader uploader = client.createUploadWithData(upload, content.length); + uploader.finish(); + + assertEquals(content.length, uploader.getOffset()); + assertNotNull(uploader.getUploadURL()); + + return new JSONObject() + .put("acceptedBytes", (int) uploader.getOffset()) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static boolean isRequired() { + return "true".equals(System.getProperty("api2DevdockTusCreationWithUpload.required")); + } +} From 561a96ee32e99e70106fac64f0bcd4be09b4fec7 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 01:27:18 +0200 Subject: [PATCH 61/63] Add Android TUS deferred-length proof --- ...ockTusDeferredLengthUploadExampleTest.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusDeferredLengthUploadExampleTest.java diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusDeferredLengthUploadExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusDeferredLengthUploadExampleTest.java new file mode 100644 index 0000000..137194b --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusDeferredLengthUploadExampleTest.java @@ -0,0 +1,111 @@ +package io.tus.android.client; + +import android.app.Activity; +import android.net.Uri; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.ConscryptMode; + +import java.io.IOException; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUploader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@RunWith(RobolectricTestRunner.class) +@ConscryptMode(ConscryptMode.Mode.OFF) +public class Api2DevdockTusDeferredLengthUploadExampleTest { + @Test + public void uploadsAndroidContentUriWithDeferredLength() throws Exception { + System.setProperty("http.strictPostRedirect", "true"); + + final String scenarioPath = Api2DevdockScenario.scenarioPath(); + if (!isRequired()) { + Assume.assumeTrue( + "API2 devdock scenario is only required through the dedicated API2 QA task", + scenarioPath != null + ); + } + if (scenarioPath == null) { + throw new IllegalStateException("API2_SDK_EXAMPLE_SCENARIO must be set"); + } + + final JSONObject scenario = Api2DevdockScenario.loadScenario(scenarioPath); + final JSONObject createResponse = + scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final Activity activity = Robolectric.setupActivity(Activity.class); + final JSONObject result = uploadWithDeferredLength(activity, scenario, createResponse); + Api2DevdockScenario.writeResult(result); + + assertEquals( + Api2DevdockScenario.scenarioBytes(scenario.getJSONObject("upload")).length, + result.getInt("acceptedBytes") + ); + assertNotNull(result.getString("uploadUrl")); + } + + private static JSONObject uploadWithDeferredLength( + Activity activity, + JSONObject scenario, + JSONObject createResponse + ) throws IOException, JSONException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + if (!uploadConfig.getBoolean("uploadLengthDeferred")) { + throw new IllegalStateException( + "deferred-length scenario must set uploadLengthDeferred" + ); + } + + final Uri uri = Api2DevdockScenario.registerContentUri( + activity, + content, + "api2-devdock-deferred-length-upload.txt" + ); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(Api2DevdockScenario.tusUrl( + uploadConfig, + scenario, + createResponse + )); + + final TusAndroidUpload upload = Api2DevdockScenario.androidUpload( + activity, + uri, + scenario, + createResponse, + scenario.getString("scenarioId") + "-android-deferred-length" + ); + upload.setUploadLengthDeferred(true); + + final TusUploader uploader = client.createUpload(upload); + uploader.setChunkSize(chunkSize); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + + assertEquals(content.length, uploader.getOffset()); + assertNotNull(uploader.getUploadURL()); + + return new JSONObject() + .put("acceptedBytes", (int) uploader.getOffset()) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static boolean isRequired() { + return "true".equals(System.getProperty("api2DevdockTusDeferredLengthUpload.required")); + } +} From 56287b55beb50ba0dc96799f76678ea23198aca1 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 02:31:09 +0200 Subject: [PATCH 62/63] Add Android TUS request lifecycle proof --- .../android/client/Api2DevdockScenario.java | 36 +++ ...ckTusRequestLifecycleHooksExampleTest.java | 227 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusRequestLifecycleHooksExampleTest.java diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java index 58f00ab..7bdfcd1 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java @@ -87,6 +87,28 @@ static final class TerminationPlan { } } + static final class RequestLifecycleHooksPlan { + final List expectedAfterResponseMethods; + final List expectedAfterResponseStatusCodes; + final List expectedBeforeRequestMethods; + final List ignoredRequestMethods; + + RequestLifecycleHooksPlan(JSONObject requestLifecycleHooks) throws JSONException { + expectedAfterResponseMethods = stringList( + requestLifecycleHooks.getJSONArray("expectedAfterResponseMethods") + ); + expectedAfterResponseStatusCodes = integerList( + requestLifecycleHooks.getJSONArray("expectedAfterResponseStatusCodes") + ); + expectedBeforeRequestMethods = stringList( + requestLifecycleHooks.getJSONArray("expectedBeforeRequestMethods") + ); + ignoredRequestMethods = stringList( + requestLifecycleHooks.getJSONArray("ignoredRequestMethods") + ); + } + } + static TusAndroidUpload androidUpload( Activity activity, Uri uri, @@ -280,6 +302,11 @@ static String uploadRequestIdHeaderName(JSONObject uploadConfig) throws JSONExce return uploadConfig.getString("requestIdHeaderName"); } + static RequestLifecycleHooksPlan requestLifecycleHooks(JSONObject uploadConfig) + throws JSONException { + return new RequestLifecycleHooksPlan(uploadConfig.getJSONObject("requestLifecycleHooks")); + } + static String uploadCallbackEventKey(UploadCallbacksPlan plan, String... parts) { final StringBuilder key = new StringBuilder(); for (int index = 0; index < parts.length; index++) { @@ -407,6 +434,15 @@ private static List stringList(JSONArray values) throws JSONException { return result; } + private static List integerList(JSONArray values) throws JSONException { + final List result = new ArrayList(); + for (int index = 0; index < values.length(); index++) { + result.add(values.getInt(index)); + } + + return result; + } + private static List> stringListList(JSONArray values) throws JSONException { final List> result = new ArrayList>(); for (int index = 0; index < values.length(); index++) { diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusRequestLifecycleHooksExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusRequestLifecycleHooksExampleTest.java new file mode 100644 index 0000000..42db544 --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusRequestLifecycleHooksExampleTest.java @@ -0,0 +1,227 @@ +package io.tus.android.client; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.net.Uri; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.ConscryptMode; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusUploader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +@ConscryptMode(ConscryptMode.Mode.OFF) +public class Api2DevdockTusRequestLifecycleHooksExampleTest { + @Test + public void observesAndroidTusRequestLifecycleHooks() throws Exception { + System.setProperty("http.strictPostRedirect", "true"); + + final String scenarioPath = Api2DevdockScenario.scenarioPath(); + if (!isRequired()) { + Assume.assumeTrue( + "API2 devdock scenario is only required through the dedicated API2 QA task", + scenarioPath != null + ); + } + if (scenarioPath == null) { + throw new IllegalStateException("API2_SDK_EXAMPLE_SCENARIO must be set"); + } + + final JSONObject scenario = Api2DevdockScenario.loadScenario(scenarioPath); + final JSONObject createResponse = + scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final Activity activity = Robolectric.setupActivity(Activity.class); + final JSONObject result = uploadWithRequestLifecycleHooks( + activity, + scenario, + createResponse + ); + Api2DevdockScenario.writeResult(result); + + assertNotNull(result.getString("uploadUrl")); + } + + private static JSONObject uploadWithRequestLifecycleHooks( + Activity activity, + JSONObject scenario, + JSONObject createResponse + ) throws IOException, JSONException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final Api2DevdockScenario.RequestLifecycleHooksPlan plan = + Api2DevdockScenario.requestLifecycleHooks(uploadConfig); + final List beforeRequestMethods = new ArrayList(); + final List afterResponseMethods = new ArrayList(); + final List afterResponseStatusCodes = new ArrayList(); + Api2DevdockScenario.requireFullFileChunkSize(uploadConfig); + + final Uri uri = Api2DevdockScenario.registerContentUri( + activity, + content, + "api2-devdock-request-lifecycle-hooks.txt" + ); + + final SharedPreferences preferences = activity.getSharedPreferences( + "api2-devdock-tus-request-lifecycle-hooks", + 0 + ); + assertTrue(preferences.edit().clear().commit()); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(Api2DevdockScenario.tusUrl( + uploadConfig, + scenario, + createResponse + )); + client.enableResuming(new TusPreferencesURLStore(preferences)); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + if (!plan.ignoredRequestMethods.contains(context.getMethod())) { + beforeRequestMethods.add(context.getMethod()); + } + } + }, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse( + TusRequestLifecycleHooks.RequestContext context + ) throws IOException { + if (!plan.ignoredRequestMethods.contains(context.getMethod())) { + afterResponseMethods.add(context.getMethod()); + afterResponseStatusCodes.add( + context.getConnection().getResponseCode() + ); + } + } + } + )); + + final TusAndroidUpload upload = Api2DevdockScenario.androidUpload( + activity, + uri, + scenario, + createResponse, + scenario.getString("scenarioId") + "-android-request-lifecycle-hooks" + ); + + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(content.length); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + + assertEquals(content.length, uploader.getOffset()); + assertNotNull(uploader.getUploadURL()); + assertStringList( + beforeRequestMethods, + plan.expectedBeforeRequestMethods, + "before request methods" + ); + assertStringList( + afterResponseMethods, + plan.expectedAfterResponseMethods, + "after response methods" + ); + assertIntegerList( + afterResponseStatusCodes, + plan.expectedAfterResponseStatusCodes, + "after response status codes" + ); + + return new JSONObject() + .put("afterResponseMethods", new JSONArray(afterResponseMethods)) + .put("afterResponseStatusCodes", new JSONArray(afterResponseStatusCodes)) + .put("beforeRequestMethods", new JSONArray(beforeRequestMethods)) + .put("uploadUrl", uploader.getUploadURL().toString()); + } + + private static void assertStringList( + List actual, + List expected, + String label + ) { + if (actual.size() != expected.size()) { + throw new IllegalStateException( + "request lifecycle hooks expected " + + label + + " " + + expected + + ", got " + + actual + ); + } + + for (int index = 0; index < expected.size(); index++) { + if (!actual.get(index).equals(expected.get(index))) { + throw new IllegalStateException( + "request lifecycle hooks expected " + + label + + " " + + expected.get(index) + + " at index " + + index + + ", got " + + actual.get(index) + ); + } + } + } + + private static void assertIntegerList( + List actual, + List expected, + String label + ) { + if (actual.size() != expected.size()) { + throw new IllegalStateException( + "request lifecycle hooks expected " + + label + + " " + + expected + + ", got " + + actual + ); + } + + for (int index = 0; index < expected.size(); index++) { + if (actual.get(index).intValue() != expected.get(index).intValue()) { + throw new IllegalStateException( + "request lifecycle hooks expected " + + label + + " " + + expected.get(index) + + " at index " + + index + + ", got " + + actual.get(index) + ); + } + } + } + + private static boolean isRequired() { + return "true".equals(System.getProperty("api2DevdockTusRequestLifecycleHooks.required")); + } +} From 21b3cdfb5167ffd0081e7f2601c2770be15bd131 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 8 Jun 2026 02:40:49 +0200 Subject: [PATCH 63/63] Add Android TUS retry offset proof --- .../android/client/Api2DevdockScenario.java | 53 ++++ ...dockTusRetryOffsetRecoveryExampleTest.java | 235 ++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusRetryOffsetRecoveryExampleTest.java diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java index 7bdfcd1..11c3fc8 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockScenario.java @@ -109,6 +109,54 @@ static final class RequestLifecycleHooksPlan { } } + static final class RetryOffsetRecoveryFailAfterResponsePlan { + final String message; + final String method; + final int occurrence; + + RetryOffsetRecoveryFailAfterResponsePlan(JSONObject failAfterResponse) + throws JSONException { + message = failAfterResponse.getString("message"); + method = failAfterResponse.getString("method"); + occurrence = failAfterResponse.getInt("occurrence"); + } + } + + static final class RetryOffsetRecoveryRecoveryResponsePlan { + final String method; + final String offsetHeader; + + RetryOffsetRecoveryRecoveryResponsePlan(JSONObject recoveryResponse) + throws JSONException { + method = recoveryResponse.getString("method"); + offsetHeader = recoveryResponse.getString("offsetHeader"); + } + } + + static final class RetryOffsetRecoveryPlan { + final int expectedFailureCount; + final int expectedRecoveredOffset; + final int expectedRecoveryRequestCount; + final List expectedRequestMethods; + final RetryOffsetRecoveryFailAfterResponsePlan failAfterResponse; + final RetryOffsetRecoveryRecoveryResponsePlan recoveryResponse; + + RetryOffsetRecoveryPlan(JSONObject retryOffsetRecovery) throws JSONException { + expectedFailureCount = retryOffsetRecovery.getInt("expectedFailureCount"); + expectedRecoveredOffset = retryOffsetRecovery.getInt("expectedRecoveredOffset"); + expectedRecoveryRequestCount = + retryOffsetRecovery.getInt("expectedRecoveryRequestCount"); + expectedRequestMethods = + stringList(retryOffsetRecovery.getJSONArray("expectedRequestMethods")); + failAfterResponse = new RetryOffsetRecoveryFailAfterResponsePlan( + retryOffsetRecovery.getJSONObject("failAfterResponse") + ); + recoveryResponse = new RetryOffsetRecoveryRecoveryResponsePlan( + retryOffsetRecovery.getJSONObject("recoveryResponse") + ); + } + } + static TusAndroidUpload androidUpload( Activity activity, Uri uri, @@ -307,6 +355,11 @@ static RequestLifecycleHooksPlan requestLifecycleHooks(JSONObject uploadConfig) return new RequestLifecycleHooksPlan(uploadConfig.getJSONObject("requestLifecycleHooks")); } + static RetryOffsetRecoveryPlan retryOffsetRecovery(JSONObject uploadConfig) + throws JSONException { + return new RetryOffsetRecoveryPlan(uploadConfig.getJSONObject("retryOffsetRecovery")); + } + static String uploadCallbackEventKey(UploadCallbacksPlan plan, String... parts) { final StringBuilder key = new StringBuilder(); for (int index = 0; index < parts.length; index++) { diff --git a/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusRetryOffsetRecoveryExampleTest.java b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusRetryOffsetRecoveryExampleTest.java new file mode 100644 index 0000000..3989eac --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/Api2DevdockTusRetryOffsetRecoveryExampleTest.java @@ -0,0 +1,235 @@ +package io.tus.android.client; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.net.Uri; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.ConscryptMode; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.List; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusExecutor; +import io.tus.java.client.TusRequestLifecycleHooks; +import io.tus.java.client.TusUploader; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +@ConscryptMode(ConscryptMode.Mode.OFF) +public class Api2DevdockTusRetryOffsetRecoveryExampleTest { + @Test + public void recoversAndroidTusOffsetAfterRetry() throws Exception { + System.setProperty("http.strictPostRedirect", "true"); + + final String scenarioPath = Api2DevdockScenario.scenarioPath(); + if (!isRequired()) { + Assume.assumeTrue( + "API2 devdock scenario is only required through the dedicated API2 QA task", + scenarioPath != null + ); + } + if (scenarioPath == null) { + throw new IllegalStateException("API2_SDK_EXAMPLE_SCENARIO must be set"); + } + + final JSONObject scenario = Api2DevdockScenario.loadScenario(scenarioPath); + final JSONObject createResponse = + scenario.getJSONObject("prepared").getJSONObject("createResponse"); + final Activity activity = Robolectric.setupActivity(Activity.class); + final JSONObject result = uploadWithRetryOffsetRecovery( + activity, + scenario, + createResponse + ); + Api2DevdockScenario.writeResult(result); + + assertEquals( + scenario.getJSONObject("upload") + .getJSONObject("retryOffsetRecovery") + .getInt("expectedFailureCount"), + result.getInt("simulatedFailureCount") + ); + assertNotNull(result.getString("uploadUrl")); + } + + private static JSONObject uploadWithRetryOffsetRecovery( + Activity activity, + JSONObject scenario, + JSONObject createResponse + ) throws IOException, JSONException, ProtocolException { + final JSONObject uploadConfig = scenario.getJSONObject("upload"); + final byte[] content = Api2DevdockScenario.scenarioBytes(uploadConfig); + final int chunkSize = Api2DevdockScenario.fixedChunkSizeBytes(uploadConfig); + final Api2DevdockScenario.RetryOffsetRecoveryPlan plan = + Api2DevdockScenario.retryOffsetRecovery(uploadConfig); + final List recoveredOffsets = new ArrayList(); + final List requestMethods = new ArrayList(); + final int[] failureCandidateCount = new int[]{0}; + final int[] simulatedFailureCount = new int[]{0}; + + final Uri uri = Api2DevdockScenario.registerContentUri( + activity, + content, + "api2-devdock-retry-offset-recovery.txt" + ); + + final SharedPreferences preferences = activity.getSharedPreferences( + "api2-devdock-tus-retry-offset-recovery", + 0 + ); + assertTrue(preferences.edit().clear().commit()); + + final TusClient client = new TusClient(); + client.setUploadCreationURL(Api2DevdockScenario.tusUrl( + uploadConfig, + scenario, + createResponse + )); + client.enableResuming(new TusPreferencesURLStore(preferences)); + client.setRequestLifecycleHooks(new TusRequestLifecycleHooks( + new TusRequestLifecycleHooks.BeforeRequest() { + @Override + public void beforeRequest(TusRequestLifecycleHooks.RequestContext context) { + requestMethods.add(context.getMethod()); + } + }, + new TusRequestLifecycleHooks.AfterResponse() { + @Override + public void afterResponse( + TusRequestLifecycleHooks.RequestContext context + ) throws IOException { + if (plan.recoveryResponse.method.equals(context.getMethod())) { + recoveredOffsets.add(readHeaderInt( + context.getConnection(), + plan.recoveryResponse.offsetHeader + )); + } + + if (!plan.failAfterResponse.method.equals(context.getMethod())) { + return; + } + + failureCandidateCount[0] += 1; + if (failureCandidateCount[0] != plan.failAfterResponse.occurrence) { + return; + } + + simulatedFailureCount[0] += 1; + throw new IOException(plan.failAfterResponse.message); + } + } + )); + + final TusAndroidUpload upload = Api2DevdockScenario.androidUpload( + activity, + uri, + scenario, + createResponse, + scenario.getString("scenarioId") + "-android-retry-offset-recovery" + ); + final String[] uploadUrl = new String[]{null}; + final long[] finalOffset = new long[]{0}; + final TusExecutor executor = new TusExecutor() { + @Override + protected void makeAttempt() throws ProtocolException, IOException { + final TusUploader uploader = client.resumeOrCreateUpload(upload); + uploader.setChunkSize(chunkSize); + uploader.setRequestPayloadSize(chunkSize); + int uploadedChunkSize; + do { + uploadedChunkSize = uploader.uploadChunk(); + } while (uploadedChunkSize > -1); + uploader.finish(); + uploadUrl[0] = uploader.getUploadURL().toString(); + finalOffset[0] = uploader.getOffset(); + } + }; + executor.setDelays(new int[uploadConfig.getInt("retries")]); + if (!executor.makeAttempts()) { + throw new IOException("retry offset recovery was interrupted"); + } + + if (uploadUrl[0] == null) { + throw new IllegalStateException( + "retry offset recovery TUS upload did not expose a URL" + ); + } + assertEquals(content.length, finalOffset[0]); + assertEquals(plan.expectedFailureCount, simulatedFailureCount[0]); + assertEquals(plan.expectedRecoveryRequestCount, recoveredOffsets.size()); + assertEquals(plan.expectedRecoveredOffset, recoveredOffsets.get(0).intValue()); + assertStringList(requestMethods, plan.expectedRequestMethods); + + return new JSONObject() + .put("recoveredOffsets", new JSONArray(recoveredOffsets)) + .put("recoveryRequestCount", recoveredOffsets.size()) + .put("requestMethods", new JSONArray(requestMethods)) + .put("simulatedFailureCount", simulatedFailureCount[0]) + .put("uploadUrl", uploadUrl[0]); + } + + private static int readHeaderInt(HttpURLConnection connection, String headerName) { + final String value = connection.getHeaderField(headerName); + final int offset; + try { + offset = Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new IllegalStateException( + "retry offset recovery expected numeric " + + headerName + + " response header, got " + + value + ); + } + if (offset < 0) { + throw new IllegalStateException( + "retry offset recovery expected non-negative offset, got " + offset + ); + } + + return offset; + } + + private static void assertStringList(List actual, List expected) { + if (actual.size() != expected.size()) { + throw new IllegalStateException( + "retry offset recovery expected request methods " + + expected + + ", got " + + actual + ); + } + + for (int index = 0; index < expected.size(); index++) { + if (!actual.get(index).equals(expected.get(index))) { + throw new IllegalStateException( + "retry offset recovery expected request method " + + expected.get(index) + + " at index " + + index + + ", got " + + actual.get(index) + ); + } + } + } + + private static boolean isRequired() { + return "true".equals(System.getProperty("api2DevdockTusRetryOffsetRecovery.required")); + } +}