Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
4cacceb
Add generated TUS protocol contract canary
kvz May 26, 2026
761a742
Regenerate TUS protocol contract fixture
kvz May 26, 2026
7151e44
Regenerate TUS feature contract fixture
kvz May 26, 2026
c692d53
Regenerate upload body protocol fixture
kvz May 27, 2026
92bc15a
Assert generated TUS upload events
kvz May 28, 2026
e833d4a
Cover TUS request lifecycle conformance
kvz May 28, 2026
6152e84
Cover TUS abort conformance
kvz May 29, 2026
b952b55
Cover TUS URL storage conformance
kvz May 29, 2026
ca665cd
Cover TUS relative Location conformance
kvz May 29, 2026
864c6db
Refresh TUS input source contract
kvz May 29, 2026
cc2974c
Refresh TUS retry state contract
kvz May 29, 2026
0c3fe51
Refresh TUS URL storage contract
kvz May 29, 2026
5126e95
Refresh TUS protocol selection contract
kvz May 29, 2026
a95b540
Refresh TUS start validation contract
kvz May 29, 2026
70bb7f0
Update detailed error conformance
kvz May 29, 2026
af0c4df
Regenerate TUS protocol fixture
kvz May 31, 2026
30c5397
Add generated TUS conformance scenarios
kvz Jun 1, 2026
bda7037
Regenerate TUS event contract
kvz Jun 1, 2026
2b0959c
Carry generated TUS event policy
kvz Jun 1, 2026
102bb9d
Keep generated event fixtures lintable
kvz Jun 1, 2026
ed60d2c
Update generated TUS retry events
kvz Jun 1, 2026
0cc01e6
Expose TUS managed upload contract
kvz Jun 1, 2026
2a261c1
Expose managed upload proof cases
kvz Jun 1, 2026
846f9db
Update managed upload proof fixture
kvz Jun 1, 2026
abde825
Add managed upload runtime proof
kvz Jun 1, 2026
48189c9
Use Android-safe managed proof server
kvz Jun 1, 2026
c603d2c
Handle chunked managed proof requests
kvz Jun 1, 2026
3880a67
Add managed upload permanent failure proof
kvz Jun 1, 2026
63ec6f4
Respect managed upload fixture lint
kvz Jun 1, 2026
6e041eb
Add managed upload retry exhaustion proof
kvz Jun 1, 2026
9799927
Add generated managed source unavailable proof
kvz Jun 1, 2026
ddd29e0
Add generated managed network deferral proof
kvz Jun 1, 2026
bd548a8
Add Android devdock TUS example proof
kvz Jun 1, 2026
4fe24e2
Declare Android example JSON errors
kvz Jun 1, 2026
698f0ed
Disable Conscrypt in devdock Android example
kvz Jun 1, 2026
711953d
Attach Android devdock content provider authority
kvz Jun 1, 2026
6836f1b
Regenerate Android managed upload headers
kvz Jun 3, 2026
9fc6997
Regenerate Android default header fixtures
kvz Jun 3, 2026
31df3ce
Add generated TUS request ID proof
kvz Jun 4, 2026
ac932d5
Regenerate TUS contract proofs
kvz Jun 4, 2026
b3f0662
Regenerate TUS deferred length proofs
kvz Jun 4, 2026
9e3edde
Regenerate TUS event alternatives
kvz Jun 4, 2026
ece947c
Regenerate TUS extra event prefixes
kvz Jun 4, 2026
3cf4810
Use generic TUS extra event matching policy
kvz Jun 4, 2026
8033f50
Regenerate TUS conformance metadata fixtures
kvz Jun 4, 2026
407537d
Update managed upload runtime capabilities fixture
kvz Jun 4, 2026
90525e2
Update managed upload outcome fixture
kvz Jun 4, 2026
b80a6f5
Update managed upload attempt fixture
kvz Jun 4, 2026
6d703fb
Update managed upload state fixture
kvz Jun 4, 2026
7b1c6b4
Group managed upload runtime fixture
kvz Jun 4, 2026
d54e526
Use generated TUS offset discovery method
kvz Jun 4, 2026
f633ff0
Regenerate exact Android TUS transport tests
kvz Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
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;
import org.junit.Test;
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;
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)
@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();
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, JSONException, 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");
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<String, String> uploadMetadata(
JSONObject uploadConfig,
JSONObject scenario,
JSONObject createResponse
) throws JSONException {
final JSONArray fields = uploadConfig.getJSONArray("metadata");
final Map<String, String> metadata = new LinkedHashMap<String, String>();
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();
}
}
}
Loading