diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ResumableUploadProgressListener.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ResumableUploadProgressListener.java new file mode 100644 index 000000000000..cb9038b79d4b --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ResumableUploadProgressListener.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.resumableupload; + +import com.google.api.core.BetaApi; +import org.jspecify.annotations.NullMarked; + +/** A callback listener for observing the progress and state transitions of a resumable upload. */ +@BetaApi +@FunctionalInterface +@NullMarked +public interface ResumableUploadProgressListener { + + /** + * Invoked when upload progress or state changes. + * + * @param status the current status snapshot of the upload + */ + void onProgress(ResumableUploadStatus status); +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ResumableUploadStatus.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ResumableUploadStatus.java new file mode 100644 index 000000000000..6a283723a4f2 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumableupload/ResumableUploadStatus.java @@ -0,0 +1,152 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.resumableupload; + +import com.google.api.core.BetaApi; +import com.google.auto.value.AutoValue; +import com.google.common.base.Preconditions; +import org.jspecify.annotations.NullMarked; + +/** Status snapshot of an ongoing resumable upload session. */ +@BetaApi +@NullMarked +@AutoValue +public abstract class ResumableUploadStatus { + + /** The state of the resumable upload session. */ + public enum State { + /** The upload session was successfully initiated with the server. */ + STARTED, + + /** A data chunk was successfully uploaded to the server. */ + UPLOADING, + + /** A recoverable error occurred; the client is initiating recovery. */ + RECOVERING, + + /** A status query succeeded and the stream offset was reconciled. */ + OFFSET_RECEIVED, + + /** The final chunk was accepted and the upload session is complete. */ + FINALIZED + } + + /** Returns the negotiated upload session URI. */ + public abstract String getUploadUrl(); + + /** Returns the number of bytes successfully uploaded to the server so far. */ + public abstract long getBytesUploaded(); + + /** Returns the total size of the upload payload in bytes, or -1 if unknown. */ + public abstract long getTotalBytes(); + + /** Returns the current state of the upload session. */ + public abstract State getState(); + + /** Returns whether the total upload size is unknown (e.g. streaming upload). */ + public boolean isIndeterminate() { + return getTotalBytes() == -1; + } + + /** Returns whether the upload has reached a completed state. */ + public boolean isCompleted() { + return getState() == State.FINALIZED; + } + + /** + * Returns the upload progress as a fraction between 0.0 and 1.0, or -1.0 if total size is + * unknown. + * + * @return progress fraction between 0.0 and 1.0, or -1.0 if unknown + */ + public double getProgressFraction() { + if (isIndeterminate()) { + return -1.0; + } + if (getTotalBytes() == 0) { + return 1.0; + } + return (double) getBytesUploaded() / getTotalBytes(); + } + + public abstract Builder toBuilder(); + + public static Builder builder() { + return new AutoValue_ResumableUploadStatus.Builder() + .setBytesUploaded(0L) + .setTotalBytes(-1L) + .setState(State.STARTED); + } + + /** + * Creates a {@link ResumableUploadStatus} snapshot. + * + * @param uploadUrl the upload session URI + * @param bytesUploaded the number of bytes uploaded so far + * @param totalBytes the total size of the payload in bytes, or -1 if unknown + * @param state the current upload state + * @return a new {@link ResumableUploadStatus} instance + */ + public static ResumableUploadStatus create( + String uploadUrl, long bytesUploaded, long totalBytes, State state) { + return builder() + .setUploadUrl(uploadUrl) + .setBytesUploaded(bytesUploaded) + .setTotalBytes(totalBytes) + .setState(state) + .build(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setUploadUrl(String uploadUrl); + + public abstract Builder setBytesUploaded(long bytesUploaded); + + public abstract Builder setTotalBytes(long totalBytes); + + public abstract Builder setState(State state); + + abstract ResumableUploadStatus autoBuild(); + + public ResumableUploadStatus build() { + ResumableUploadStatus status = autoBuild(); + Preconditions.checkArgument( + status.getBytesUploaded() >= 0, "bytesUploaded must be non-negative"); + Preconditions.checkArgument(status.getTotalBytes() >= -1, "totalBytes must be >= -1"); + if (status.getTotalBytes() >= 0) { + Preconditions.checkArgument( + status.getBytesUploaded() <= status.getTotalBytes(), + "bytesUploaded must be <= totalBytes"); + } + return status; + } + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumableupload/ResumableUploadStatusTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumableupload/ResumableUploadStatusTest.java new file mode 100644 index 000000000000..3765215f805d --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/resumableupload/ResumableUploadStatusTest.java @@ -0,0 +1,150 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.resumableupload; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class ResumableUploadStatusTest { + + private static final String UPLOAD_URL = "https://storage.googleapis.com/upload/session/12345"; + + @Test + void status_enforcesBoundsAndInvariants() { + // Valid status: bytesUploaded >= 0, totalBytes == -1 (indeterminate) + ResumableUploadStatus streamingStatus = + ResumableUploadStatus.create( + UPLOAD_URL, 500L, -1L, ResumableUploadStatus.State.UPLOADING); + assertThat(streamingStatus.getBytesUploaded()).isEqualTo(500L); + assertThat(streamingStatus.getTotalBytes()).isEqualTo(-1L); + assertThat(streamingStatus.isIndeterminate()).isTrue(); + assertThat(streamingStatus.isCompleted()).isFalse(); + + // Valid status: known totalBytes with bytesUploaded <= totalBytes + ResumableUploadStatus knownStatus = + ResumableUploadStatus.create( + UPLOAD_URL, 1000L, 2000L, ResumableUploadStatus.State.UPLOADING); + assertThat(knownStatus.getBytesUploaded()).isEqualTo(1000L); + assertThat(knownStatus.getTotalBytes()).isEqualTo(2000L); + assertThat(knownStatus.isIndeterminate()).isFalse(); + assertThat(knownStatus.isCompleted()).isFalse(); + + // Invalid: negative bytesUploaded + assertThrows( + IllegalArgumentException.class, + () -> + ResumableUploadStatus.create( + UPLOAD_URL, -1L, 1000L, ResumableUploadStatus.State.UPLOADING)); + + // Invalid: totalBytes < -1 + assertThrows( + IllegalArgumentException.class, + () -> + ResumableUploadStatus.create( + UPLOAD_URL, 0L, -2L, ResumableUploadStatus.State.STARTED)); + + // Invalid: bytesUploaded > totalBytes when totalBytes >= 0 + assertThrows( + IllegalArgumentException.class, + () -> + ResumableUploadStatus.create( + UPLOAD_URL, 2001L, 2000L, ResumableUploadStatus.State.UPLOADING)); + } + + @Test + void status_calculatesProgressFractionCorrectly() { + // Indeterminate stream (-1) + ResumableUploadStatus indeterminate = + ResumableUploadStatus.create( + UPLOAD_URL, 512L, -1L, ResumableUploadStatus.State.UPLOADING); + assertThat(indeterminate.getProgressFraction()).isEqualTo(-1.0); + + // 0-byte upload + ResumableUploadStatus zeroByte = + ResumableUploadStatus.create( + UPLOAD_URL, 0L, 0L, ResumableUploadStatus.State.FINALIZED); + assertThat(zeroByte.getProgressFraction()).isEqualTo(1.0); + assertThat(zeroByte.isCompleted()).isTrue(); + + // Halfway uploaded + ResumableUploadStatus halfway = + ResumableUploadStatus.create( + UPLOAD_URL, 500L, 1000L, ResumableUploadStatus.State.UPLOADING); + assertThat(halfway.getProgressFraction()).isEqualTo(0.5); + + // Completed upload + ResumableUploadStatus completed = + ResumableUploadStatus.create( + UPLOAD_URL, 1000L, 1000L, ResumableUploadStatus.State.FINALIZED); + assertThat(completed.getProgressFraction()).isEqualTo(1.0); + assertThat(completed.isCompleted()).isTrue(); + } + + @Test + void status_builderAndToBuilderSupport() { + ResumableUploadStatus initial = + ResumableUploadStatus.builder() + .setUploadUrl(UPLOAD_URL) + .setBytesUploaded(0L) + .setTotalBytes(5000L) + .setState(ResumableUploadStatus.State.STARTED) + .build(); + + assertThat(initial.getState()).isEqualTo(ResumableUploadStatus.State.STARTED); + assertThat(initial.getBytesUploaded()).isEqualTo(0L); + + ResumableUploadStatus updated = + initial.toBuilder() + .setBytesUploaded(2500L) + .setState(ResumableUploadStatus.State.UPLOADING) + .build(); + + assertThat(updated.getBytesUploaded()).isEqualTo(2500L); + assertThat(updated.getState()).isEqualTo(ResumableUploadStatus.State.UPLOADING); + assertThat(updated.getProgressFraction()).isEqualTo(0.5); + } + + @Test + void progressListener_dispatchesStatusSnapshot() { + AtomicReference capturedStatus = new AtomicReference<>(); + ResumableUploadProgressListener listener = capturedStatus::set; + + ResumableUploadStatus status = + ResumableUploadStatus.create( + UPLOAD_URL, 1024L, 2048L, ResumableUploadStatus.State.UPLOADING); + listener.onProgress(status); + + assertThat(capturedStatus.get()).isEqualTo(status); + assertThat(capturedStatus.get().getProgressFraction()).isEqualTo(0.5); + } +}