diff --git a/src/main/java/io/appium/java_client/plugins/storage/StorageClient.java b/src/main/java/io/appium/java_client/plugins/storage/StorageClient.java
new file mode 100644
index 000000000..013782ec8
--- /dev/null
+++ b/src/main/java/io/appium/java_client/plugins/storage/StorageClient.java
@@ -0,0 +1,248 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.appium.java_client.plugins.storage;
+
+import org.openqa.selenium.WebDriverException;
+import org.openqa.selenium.json.Json;
+import org.openqa.selenium.remote.ErrorCodec;
+import org.openqa.selenium.remote.codec.AbstractHttpResponseCodec;
+import org.openqa.selenium.remote.codec.w3c.W3CHttpResponseCodec;
+import org.openqa.selenium.remote.http.ClientConfig;
+import org.openqa.selenium.remote.http.Contents;
+import org.openqa.selenium.remote.http.HttpClient;
+import org.openqa.selenium.remote.http.HttpHeader;
+import org.openqa.selenium.remote.http.HttpMethod;
+import org.openqa.selenium.remote.http.HttpRequest;
+import org.openqa.selenium.remote.http.HttpResponse;
+import org.openqa.selenium.remote.http.WebSocket;
+
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+import static io.appium.java_client.plugins.storage.StorageUtils.calcSha1Digest;
+import static io.appium.java_client.plugins.storage.StorageUtils.streamFileToWebSocket;
+
+/**
+ * This is a Java implementation of the Appium server storage plugin client.
+ * See the plugin README
+ * for more details.
+ */
+public class StorageClient {
+ public static final String PREFIX = "/storage";
+ private final Json json = new Json();
+ private final AbstractHttpResponseCodec responseCodec = new W3CHttpResponseCodec();
+ private final ErrorCodec errorCodec = ErrorCodec.createDefault();
+
+ private final URL baseUrl;
+ private final HttpClient httpClient;
+
+ public StorageClient(URL baseUrl) {
+ this.baseUrl = baseUrl;
+ this.httpClient = HttpClient.Factory.createDefault().createClient(baseUrl);
+ }
+
+ public StorageClient(ClientConfig clientConfig) {
+ this.httpClient = HttpClient.Factory.createDefault().createClient(clientConfig);
+ this.baseUrl = clientConfig.baseUrl();
+ }
+
+ /**
+ * Adds a local file to the server storage.
+ * The remote file name is be set to the same value as the local file name.
+ *
+ * @param file File instance.
+ */
+ public void add(File file) {
+ add(file, file.getName());
+ }
+
+ /**
+ * Adds a local file to the server storage.
+ *
+ * @param file File instance.
+ * @param name The remote file name.
+ */
+ public void add(File file, String name) {
+ var request = new HttpRequest(HttpMethod.POST, formatPath(baseUrl, PREFIX, "add").toString());
+ var httpResponse = httpClient.execute(setJsonPayload(request, Map.of(
+ "name", name,
+ "sha1", calcSha1Digest(file)
+ )));
+ Map value = requireResponseValue(httpResponse);
+ final var wsTtlMs = (Long) value.get("ttlMs");
+ //noinspection unchecked
+ var wsInfo = (Map) value.get("ws");
+ var streamWsPathname = (String) wsInfo.get("stream");
+ var eventWsPathname = (String) wsInfo.get("events");
+ final var completion = new CountDownLatch(1);
+ final var lastException = new AtomicReference(null);
+ try (var streamWs = httpClient.openSocket(
+ new HttpRequest(HttpMethod.POST, formatPath(baseUrl, streamWsPathname).toString()),
+ new WebSocket.Listener() {}
+ ); var eventsWs = httpClient.openSocket(
+ new HttpRequest(HttpMethod.POST, formatPath(baseUrl, eventWsPathname).toString()),
+ new EventWsListener(lastException, completion)
+ )) {
+ streamFileToWebSocket(file, streamWs);
+ streamWs.close();
+ if (!completion.await(wsTtlMs, TimeUnit.MILLISECONDS)) {
+ throw new IllegalStateException(String.format(
+ "Could not receive a confirmation about adding '%s' to the server storage within %sms timeout",
+ name, wsTtlMs
+ ));
+ }
+ var exc = lastException.get();
+ if (exc != null) {
+ throw exc instanceof RuntimeException ? (RuntimeException) exc : new WebDriverException(exc);
+ }
+ } catch (InterruptedException e) {
+ throw new WebDriverException(e);
+ }
+ }
+
+ /**
+ * Lists items that exist in the storage.
+ *
+ * @return All storage items.
+ */
+ public List list() {
+ var request = new HttpRequest(HttpMethod.GET, formatPath(baseUrl, PREFIX, "list").toString());
+ var httpResponse = httpClient.execute(request);
+ List