diff --git a/example/build.gradle b/example/build.gradle index 6440f38..4d533db 100644 --- a/example/build.gradle +++ b/example/build.gradle @@ -3,13 +3,17 @@ plugins { } android { - compileSdkVersion 34 - buildToolsVersion '30.0.3' + compileSdk 35 + buildToolsVersion '35.0.0' + + buildFeatures { + viewBinding true + } defaultConfig { applicationId 'io.tus.android.example' minSdkVersion 21 - targetSdkVersion 34 + targetSdk 35 versionCode 1 versionName '1.0' } diff --git a/example/src/main/java/io/tus/android/example/MainActivity.java b/example/src/main/java/io/tus/android/example/MainActivity.java index 84f9e88..1815120 100644 --- a/example/src/main/java/io/tus/android/example/MainActivity.java +++ b/example/src/main/java/io/tus/android/example/MainActivity.java @@ -1,186 +1,137 @@ package io.tus.android.example; import android.app.AlertDialog; -import android.content.Intent; -import android.content.SharedPreferences; import android.net.Uri; import android.os.AsyncTask; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.widget.ContentLoadingProgressBar; - import android.os.Bundle; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.Button; -import android.widget.TextView; +import android.util.Log; +import android.util.Pair; -import com.google.android.material.button.MaterialButton; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.PickVisualMediaRequest; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; -import java.net.URL; +import java.io.File; +import java.util.Collection; +import java.util.Map; -import io.tus.android.client.TusAndroidUpload; -import io.tus.android.client.TusPreferencesURLStore; -import io.tus.java.client.TusClient; -import io.tus.java.client.TusUpload; -import io.tus.java.client.TusUploader; +import io.tus.android.client.TusAndroidClient; +import io.tus.android.example.databinding.ActivityMainBinding; public class MainActivity extends AppCompatActivity { - private final int REQUEST_FILE_SELECT = 1; - private TusClient client; - private TextView status; - private MaterialButton pauseButton; - private MaterialButton resumeButton; - private UploadTask uploadTask; - private ContentLoadingProgressBar progressBar; - private Uri fileUri; + + private static final String LOG_TAG = MainActivity.class.toString(); + private ActivityMainBinding binding; + private TusAndroidClient tusAndroidClient; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - try { - SharedPreferences pref = getSharedPreferences("tus", 0); - client = new TusClient(); - client.setUploadCreationURL(new URL("https://tusd.tusdemo.net/files/")); - client.enableResuming(new TusPreferencesURLStore(pref)); - } catch (Exception e) { - showError(e); - } - - status = (TextView) findViewById(R.id.status); - progressBar = (ContentLoadingProgressBar) findViewById(R.id.progressBar); - - Button button = (Button) findViewById(R.id.button); - button.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - Intent intent = new Intent(); - intent.setType("*/*"); - intent.setAction(Intent.ACTION_GET_CONTENT); - startActivityForResult(Intent.createChooser(intent, "Select file to upload"), REQUEST_FILE_SELECT); - - } - }); - - pauseButton = (MaterialButton) findViewById(R.id.pause_button); - pauseButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - pauseUpload(); + binding = ActivityMainBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + // create the upload client - this allows us to submit new uploads and get updates on the status of previously submitted uploads + // uploads we submitted previously will continue in the background, regardless of whether we initialise the TusAndroidClient next time + tusAndroidClient = new TusAndroidClient(getApplicationContext(), Uri.parse("https://tusd.tusdemo.net/files/"), new File(getFilesDir().getPath() + "/internal-tus-files-folder/")); + // get the latest info we have on the status of our uploads. This info may not be available immediately, so pass a callback - this will only be called once + tusAndroidClient.getPendingUploadInfo(this::updateStatsDisplay); + // register to receive ongoing updates as the upload status changes + tusAndroidClient.addPendingUploadChangeListener(this::updateStatsDisplay); + // register to be notified when individual uploads succeed: + tusAndroidClient.addUploadSuccessListener(succeededUploadInfo -> Log.e(LOG_TAG, "upload " + succeededUploadInfo.id + " succeeded!")); + + ActivityResultLauncher pickMultipleMedia = registerForActivityResult(new ActivityResultContracts.PickMultipleVisualMedia(10), uris -> { + if (!uris.isEmpty()) { + beginUpload(uris); } }); - resumeButton = (MaterialButton) findViewById(R.id.resume_button); - resumeButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - resumeUpload(); - } + binding.uploadButton.setOnClickListener(v -> { + pickMultipleMedia.launch(new PickVisualMediaRequest.Builder() + .setMediaType(ActivityResultContracts.PickVisualMedia.ImageAndVideo.INSTANCE) + .build()); }); } - private void beginUpload(Uri uri) { - fileUri = uri; - resumeUpload(); - } - - public void setPauseButtonEnabled(boolean enabled) { - pauseButton.setEnabled(enabled); - resumeButton.setEnabled(!enabled); - } - - public void pauseUpload() { - uploadTask.cancel(false); - } - - public void resumeUpload() { - try { - TusUpload upload = new TusAndroidUpload(fileUri, this); - uploadTask = new UploadTask(this, client, upload); - uploadTask.execute(new Void[0]); - } catch (Exception e) { - showError(e); + private void updateStatsDisplay(@NonNull TusAndroidClient.UploadStateInfo uploadStateInfo) { + // UploadStateInfo contains... + // 1) uploadsSucceeded: all the uploads that have succeeded in the background after the app was last running, plus any uploads that have succeeded this time + binding.buttonStatsNumSucceeded.setText(getString(R.string.num_succeeded, uploadStateInfo.uploadsSucceeded.size())); + // 2) uploadsPending: all the uploads (submitted now or in previous times using the app) which have not yet succeeded of permanently failed + // they may be running, or schedule to run in future + Pair scheduleAndRunningCount = countScheduledAndRunning(uploadStateInfo.uploadsPending); + binding.buttonStatsNumScheduled.setText(getString(R.string.num_scheduled, scheduleAndRunningCount.first)); + binding.buttonStatsNumRunning.setText(getString(R.string.num_running, scheduleAndRunningCount.second)); + // 3) uploadsFailed: uploads that have failed permanently in the background after the app was last running, plus any uploads that have permanently failed this time + // to permanently fail, we either received an unrecoverable error from TUS backend, or exceeded a time or retry limit + binding.buttonStatsNumPermanentlyFailed.setText(getString(R.string.num_failed, uploadStateInfo.uploadsFailed.size())); + + // For each upload we have some information, including a unique generated id, and the upload metadata + // In-progress uploads contain their state (SCHEDULED or RUNNING) and progress + StringBuilder infoDisplay = new StringBuilder(); + for (TusAndroidClient.PendingUploadInfo info : uploadStateInfo.uploadsPending.values()) { + infoDisplay.append("id: ").append(info.id).append("\n") + .append(" state: ").append(info.state).append("\n") + .append(" progress: ").append((int) info.progress).append("%\n"); + if (info.mostRecentFailureReasonIfAny != null) { + infoDisplay.append(" previously failed because: ").append(info.mostRecentFailureDetailsIfAny); + } + infoDisplay.append("\n\n"); } + binding.buttonStatsAllInfo.setText(uploadStateInfo.uploadsPending.isEmpty() ? getString(R.string.stats_description) : infoDisplay.toString()); + updateProgressBar(uploadStateInfo.uploadsPending); } - private void setStatus(String text) { - status.setText(text); - } - - private void setUploadProgress(int progress) { - progressBar.setProgress(progress); - } - - private class UploadTask extends AsyncTask { - private MainActivity activity; - private TusClient client; - private TusUpload upload; - private Exception exception; - - public UploadTask(MainActivity activity, TusClient client, TusUpload upload) { - this.activity = activity; - this.client = client; - this.upload = upload; - } - - @Override - protected void onPreExecute() { - activity.setStatus("Upload selected..."); - activity.setPauseButtonEnabled(true); - activity.setUploadProgress(0); - } - - @Override - protected void onPostExecute(URL uploadURL) { - activity.setStatus("Upload finished!\n" + uploadURL.toString()); - activity.setPauseButtonEnabled(false); - } - - @Override - protected void onCancelled() { - if (exception != null) { - activity.showError(exception); + private Pair countScheduledAndRunning(Map pendingUploadsInfo) { + int scheduledCount = 0; + int runningCount = 0; + + for (TusAndroidClient.PendingUploadInfo info : pendingUploadsInfo.values()) { + switch (info.state) { + case SCHEDULED: + ++scheduledCount; + break; + case RUNNING: + ++runningCount; + break; } - - activity.setPauseButtonEnabled(false); } + return Pair.create(scheduledCount, runningCount); + } - @Override - protected void onProgressUpdate(Long... updates) { - long uploadedBytes = updates[0]; - long totalBytes = updates[1]; - activity.setStatus("Uploaded " + (int) ((double) uploadedBytes / totalBytes * 100) + "% | " + String.format("%d/%d.", uploadedBytes, totalBytes)); - activity.setUploadProgress((int) ((double) uploadedBytes / totalBytes * 100)); + private void updateProgressBar(Map pendingUploadsInfo) { + // set the progress bar to represent all the pending uploads + // e.g if there are 3 uploads pending, 2 are 90% done and one is 20% done + // the progress bar will show overall we are 66% done + double progress = 0; + for (TusAndroidClient.PendingUploadInfo info : pendingUploadsInfo.values()) { + progress += info.progress; } + if (!pendingUploadsInfo.isEmpty()) { + progress /= pendingUploadsInfo.size(); + } + binding.progressBar.setProgress((int) progress); + } - @Override - protected URL doInBackground(Void... params) { - try { - TusUploader uploader = client.resumeOrCreateUpload(upload); - long totalBytes = upload.getSize(); - long uploadedBytes = uploader.getOffset(); - - // Upload file in 1MiB chunks - uploader.setChunkSize(1024 * 1024); - - while (!isCancelled() && uploader.uploadChunk() > 0) { - uploadedBytes = uploader.getOffset(); - publishProgress(uploadedBytes, totalBytes); + private void beginUpload(Collection uris) { + AsyncTask.execute(() -> { + for (Uri uri : uris) { + try { + // when data is submitted for upload, it is copied into the storage directory specified when you created the TusAndroidClient + // it stays there until the upload either succeeds, or fails permanently + // we use Android's WorkManager to ensure the upload happens in the background when appropriate conditions (like being connected to the internet) are met + String id = tusAndroidClient.submitFileForUpload(uri); + Log.d(LOG_TAG, "file submitted, id: " + id); + } catch (TusAndroidClient.FileSubmissionException e) { + // this error could occur if we're unable to copy the file locally + showError(e); } - - uploader.finish(); - return uploader.getUploadURL(); - - } catch (Exception e) { - exception = e; - cancel(true); } - return null; - } + }); + } private void showError(Exception e) { @@ -189,19 +140,6 @@ private void showError(Exception e) { builder.setMessage(e.getMessage()); AlertDialog dialog = builder.create(); dialog.show(); - e.printStackTrace(); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (resultCode != RESULT_OK) { - return; - } - - if (requestCode == REQUEST_FILE_SELECT) { - Uri uri = data.getData(); - beginUpload(uri); - } + Log.e(LOG_TAG, "an error occurred", e); } } diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml index 5ed5068..7ae0151 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_main.xml @@ -1,80 +1,79 @@ - + android:layout_height="match_parent"> - + android:gravity="fill" + android:paddingLeft="@dimen/activity_horizontal_margin" + android:paddingTop="@dimen/activity_vertical_margin" + android:paddingRight="@dimen/activity_horizontal_margin" + android:paddingBottom="@dimen/activity_vertical_margin" + tools:context=".MainActivity"> - + - + - + - + - + + + + + android:progress="0" /> - + android:layout_below="@id/progressBar" + android:layout_centerHorizontal="true" + android:layout_marginTop="16dp" + android:text="@string/upload" /> - + + diff --git a/example/src/main/res/values/strings.xml b/example/src/main/res/values/strings.xml index 2bd3225..af54f77 100644 --- a/example/src/main/res/values/strings.xml +++ b/example/src/main/res/values/strings.xml @@ -1,7 +1,15 @@ - AndroidExample - Settings + TUS Android Client Example + File uploading test for tus.io. \n\nFile uploads will continue in the background and you can see their status when you reopen the app.\n\nTry turning off internet to cause upload delay. + Any uploads in progress or awaiting retry will be listed here + + Num Scheduled: %d + Num Running: %d + Num Succeeded: %d + Num Failed: %d + Metered Only + Upload diff --git a/tus-android-client/build.gradle b/tus-android-client/build.gradle index 2f384dd..4e2db6d 100644 --- a/tus-android-client/build.gradle +++ b/tus-android-client/build.gradle @@ -4,12 +4,12 @@ plugins { } android { - compileSdk 33 - setBuildToolsVersion("30.0.3") + compileSdk 35 + setBuildToolsVersion("35.0.0") defaultConfig { minSdkVersion 14 - targetSdk 33 + targetSdk 35 } buildTypes { release { @@ -31,8 +31,14 @@ version=config.version dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'io.tus.java.client:tus-java-client:0.5.0' + implementation 'androidx.annotation:annotation-jvm:1.9.1' + implementation 'androidx.work:work-runtime:2.10.0' + implementation 'joda-time:joda-time:2.9.1' + implementation 'com.google.code.gson:gson:2.10.1' + testImplementation 'org.robolectric:robolectric:4.14.1' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.hamcrest:hamcrest:3.0' } afterEvaluate { diff --git a/tus-android-client/src/main/java/io/tus/android/client/BetweenWorkDataStore.java b/tus-android-client/src/main/java/io/tus/android/client/BetweenWorkDataStore.java new file mode 100644 index 0000000..9e32984 --- /dev/null +++ b/tus-android-client/src/main/java/io/tus/android/client/BetweenWorkDataStore.java @@ -0,0 +1,74 @@ +package io.tus.android.client; + + +import static io.tus.android.client.TusAndroidClient.TUS_SHARED_PREFS_NAME; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.NonNull; + +import com.google.gson.JsonSyntaxException; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +/** + * Used to persist information about a job across app launches, also provides a way for workers to share additional data (such as most recent failure details). + */ +/*package*/ class BetweenWorkDataStore { + private final SharedPreferences mSharedPrefs; + + private static BetweenWorkDataStore sInstance; + + private BetweenWorkDataStore(@NonNull Context context) { + mSharedPrefs = context.getSharedPreferences(TUS_SHARED_PREFS_NAME, Context.MODE_PRIVATE); + } + + @NonNull + public static BetweenWorkDataStore get(@NonNull Context context) { + Objects.requireNonNull(context); + if (sInstance == null) { + sInstance = new BetweenWorkDataStore(context); + } + return sInstance; + } + + public void storeData(@NonNull String key, @NonNull Map data) { + Objects.requireNonNull(key); + Objects.requireNonNull(data); + mSharedPrefs.edit().putString(key, GsonUtils.GsonMarshaller.getInstance().marshal(data)).apply(); + } + + public Map getData(@NonNull String key) { + String stringifiedData = mSharedPrefs.getString(key, null); + if (stringifiedData == null) { + // bad state: the id wasn't stored + return null; + } + Map data; + try { + data = GsonUtils.GsonUnmarshaller.create(Map.class).unmarshal(stringifiedData); + } catch (JsonSyntaxException e) { + // bad state: malformed stuff stored with this id + return null; + } + return data; + } + + public void clearData(@NonNull String key) { + Objects.requireNonNull(key); + clearData(Collections.singleton(key)); + } + + public void clearData(@NonNull Collection keys) { + Objects.requireNonNull(keys); + SharedPreferences.Editor editor = mSharedPrefs.edit(); + for (String key : keys) { + editor.remove(key); + } + editor.apply(); + } +} diff --git a/tus-android-client/src/main/java/io/tus/android/client/FileCleanupWorker.java b/tus-android-client/src/main/java/io/tus/android/client/FileCleanupWorker.java new file mode 100644 index 0000000..e79726a --- /dev/null +++ b/tus-android-client/src/main/java/io/tus/android/client/FileCleanupWorker.java @@ -0,0 +1,60 @@ +package io.tus.android.client; + + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.work.Data; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import java.io.File; + +/** + * This worker deletes the file from internal storage, where it was kept until the upload succeeded. + */ +public class FileCleanupWorker extends Worker { + + // INPUT + public static final String PATH_TO_FILE_TO_DELETE = "PATH_TO_FILE_TO_DELETE"; + + // OUTPUT + public static final String FAILURE_REASON_ENUM = "FAILURE_REASON_ENUM"; + + public enum FailureReason { + ILLEGAL_ARGUMENT, + NOT_DELETED + } + + public FileCleanupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + } + + @NonNull + @Override + public Result doWork() { + String fileLocation = getInputData().getString(PATH_TO_FILE_TO_DELETE); + if (fileLocation == null) { + return Result.failure(new Data.Builder() + .putInt(FAILURE_REASON_ENUM, FailureReason.ILLEGAL_ARGUMENT.ordinal()).build()); + } + String path = Uri.parse(fileLocation).getPath(); + if (path == null) { + return Result.failure(new Data.Builder() + .putInt(FAILURE_REASON_ENUM, FailureReason.ILLEGAL_ARGUMENT.ordinal()).build()); + } + if (new File(path).delete()) { + return Result.success(); + } else { + return Result.failure(new Data.Builder() + .putInt(FAILURE_REASON_ENUM, FailureReason.NOT_DELETED.ordinal()).build()); + } + } + + public static Data createInputData(@NonNull File file) { + Data.Builder builder = new Data.Builder(); + builder.putString(FileCleanupWorker.PATH_TO_FILE_TO_DELETE, file.getPath()); + return builder.build(); + } +} \ No newline at end of file diff --git a/tus-android-client/src/main/java/io/tus/android/client/GsonUtils.java b/tus-android-client/src/main/java/io/tus/android/client/GsonUtils.java new file mode 100644 index 0000000..e429539 --- /dev/null +++ b/tus-android-client/src/main/java/io/tus/android/client/GsonUtils.java @@ -0,0 +1,43 @@ +package io.tus.android.client; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +/*package*/ final class GsonUtils { + + private static final Gson GSON = new GsonBuilder() + .enableComplexMapKeySerialization() + .create(); + + public static final class GsonMarshaller { + private static final GsonMarshaller INSTANCE = new GsonMarshaller(); + + public static GsonMarshaller getInstance() { + return INSTANCE; + } + + public String marshal(Object value) { + return GSON.toJson(value); + } + + private GsonMarshaller() {} + } + + public static class GsonUnmarshaller { + private final Class mClassOfT; + + public GsonUnmarshaller(Class classOfT) { + mClassOfT = classOfT; + } + + public static GsonUnmarshaller create(Class classOfT) { + return new GsonUnmarshaller(classOfT); + } + + public final T unmarshal(String input) { + return GSON.fromJson(input, mClassOfT); + } + } + + private GsonUtils() {} +} diff --git a/tus-android-client/src/main/java/io/tus/android/client/TusAndroidClient.java b/tus-android-client/src/main/java/io/tus/android/client/TusAndroidClient.java new file mode 100644 index 0000000..412fb88 --- /dev/null +++ b/tus-android-client/src/main/java/io/tus/android/client/TusAndroidClient.java @@ -0,0 +1,599 @@ +package io.tus.android.client; + +import android.content.Context; +import android.net.Uri; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.work.Constraints; +import androidx.work.ExistingWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; +import androidx.work.WorkQuery; +import androidx.work.WorkRequest; + +import org.joda.time.DateTime; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +public class TusAndroidClient { + + /*package*/ static final String TUS_SHARED_PREFS_NAME = "TUS_SHARED_PREF"; + + private static final String TUS_UPLOAD_TAG = "tus-upload"; + private static final String TUS_UPLOAD_ID_TAG_PREFIX = "upload-id:"; + private static final String DELETE_FILE_TAG = "delete-file"; + private static final int CHUNK_SIZE = 1024; + private static final String LOG_TAG = TusAndroidClient.class.toString(); + + private final Context mContext; + private final Uri mUploadUri; + private final File mStorageDirectory; + + private final Object mWorkInfoUpdateLock = new Object(); + @Nullable // null before we get the info on our pending workers for the first time + private UploadStateInfo mLastUpdatedUploadState; + private final Collection mCallbacksAwaitingOneTimeInfo = new CopyOnWriteArrayList<>(); + private final Collection mCallbacksAlwaysAwaitingInfo = new CopyOnWriteArrayList<>(); + private final Collection mCallbacksAlwaysAwaitingSuccess = new CopyOnWriteArrayList<>(); + private final Collection mCallbacksAlwaysAwaitingFailure = new CopyOnWriteArrayList<>(); + + + public static class UploadStateInfo { + @NonNull + public final Map uploadsSucceeded; + @NonNull + public final Map uploadsPending; + @NonNull + public final Map uploadsFailed; + + private UploadStateInfo(@NonNull Map uploadsSucceeded, @NonNull Map uploadsPending, @NonNull Map uploadsFailed) { + this.uploadsSucceeded = Collections.unmodifiableMap(uploadsSucceeded); + this.uploadsPending = Collections.unmodifiableMap(uploadsPending); + this.uploadsFailed = Collections.unmodifiableMap(uploadsFailed); + } + } + + public static abstract class UploadInfo { + @NonNull + public final String id; + @NonNull + public final Map metadata; + + protected UploadInfo(@NonNull String id, @NonNull Map metadata) { + this.id = id; + this.metadata = Collections.unmodifiableMap(metadata); + } + } + + public static class SucceededUploadInfo extends UploadInfo { + + protected SucceededUploadInfo(@NonNull String id, @NonNull Map metadata) { + super(id, metadata); + } + } + + public static class PendingUploadInfo extends UploadInfo { + public enum State { + SCHEDULED, RUNNING + } + + @NonNull + public final State state; + public final double progress; + @Nullable + public final TusUploadWorker.FailureReason mostRecentFailureReasonIfAny; + @Nullable + public final String mostRecentFailureDetailsIfAny; + + private PendingUploadInfo(@NonNull String id, @NonNull State state, @NonNull Map metadata, double progress, @Nullable TusUploadWorker.FailureReason mostRecentFailureReasonIfAny, @Nullable String mostRecentFailureDetailsIfAny) { + super(id, metadata); + this.state = state; + this.progress = progress; + this.mostRecentFailureReasonIfAny = mostRecentFailureReasonIfAny; + this.mostRecentFailureDetailsIfAny = mostRecentFailureDetailsIfAny; + } + } + + public static class PermanentlyFailedUploadInfo extends UploadInfo{ + + @Nullable + public final TusUploadWorker.FailureReason mostRecentFailureReason; + @Nullable + public final String mostRecentFailureDetails; + + private PermanentlyFailedUploadInfo(@NonNull String id, @NonNull Map metadata, @Nullable TusUploadWorker.FailureReason mostRecentFailureReason, @Nullable String mostRecentFailureDetails) { + super(id, metadata); + this.mostRecentFailureReason = mostRecentFailureReason; + this.mostRecentFailureDetails = mostRecentFailureDetails; + } + } + + public interface UploadInfoChangedCallback { + void onUpdatedUploadInfoAvailable(@NonNull UploadStateInfo uploadStateInfo); + } + + public interface UploadSucceededCallback { + void onUploadSucceeded(@NonNull SucceededUploadInfo succeededUploadInfo); + } + + public interface UploadFailedPermanentlyCallback { + void onUploadFailed(@NonNull PermanentlyFailedUploadInfo failedUploadInfo); + } + + public TusAndroidClient(@NonNull Context applicationContext, @NonNull Uri uploadUrl, @NonNull File storageDirectory) { + Objects.requireNonNull(applicationContext, "application context is required"); + Objects.requireNonNull(uploadUrl, "server upload url is required"); + Objects.requireNonNull(storageDirectory, "storage directory is required"); + + mContext = applicationContext.getApplicationContext(); + mUploadUri = uploadUrl; + mStorageDirectory = storageDirectory; + + LiveData> pendingUploads = WorkManager.getInstance(mContext).getWorkInfosLiveData(WorkQuery.Builder + .fromTags(Collections.singletonList(TUS_UPLOAD_TAG)).addStates(Arrays.asList(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED, WorkInfo.State.SUCCEEDED)).build()); + pendingUploads.observeForever(workInfos -> { + UploadStateInfo oldState = mLastUpdatedUploadState; + Collection idsToRemoveFromDataStore; + synchronized (mWorkInfoUpdateLock) { + idsToRemoveFromDataStore = updateStateForWorkInfos(workInfos); + } + BetweenWorkDataStore.get(mContext).clearData(idsToRemoveFromDataStore); + + notifyListenersAsAppropriate(oldState, Objects.requireNonNull(mLastUpdatedUploadState)); + }); + } + + private void notifyListenersAsAppropriate(@Nullable UploadStateInfo oldState, @NonNull UploadStateInfo newState) { + notifyOneTimeUpdateListeners(newState); + notifyUpdateListeners(newState); + if (oldState != null) { + if (newState.uploadsSucceeded.size() > oldState.uploadsSucceeded.size()) { + for (SucceededUploadInfo entry : newState.uploadsSucceeded.values()) { + if (!oldState.uploadsSucceeded.containsKey(entry.id)) { + notifySuccessListeners(entry); + } + } + } + if (newState.uploadsFailed.size() > oldState.uploadsFailed.size()) { + for (PermanentlyFailedUploadInfo entry : newState.uploadsFailed.values()) { + if (!oldState.uploadsFailed.containsKey(entry.id)) { + notifyPermanentFailureListeners(entry); + } + } + } + } + } + + private void notifyOneTimeUpdateListeners(@NonNull UploadStateInfo newState) { + for (UploadInfoChangedCallback callback : mCallbacksAwaitingOneTimeInfo) { + callback.onUpdatedUploadInfoAvailable(newState); + } + mCallbacksAwaitingOneTimeInfo.clear(); + } + + private void notifyUpdateListeners(@NonNull UploadStateInfo newState) { + for (UploadInfoChangedCallback callback : mCallbacksAlwaysAwaitingInfo) { + callback.onUpdatedUploadInfoAvailable(newState); + } + } + + private void notifySuccessListeners(@NonNull SucceededUploadInfo succeededUploadInfo) { + for (UploadSucceededCallback callback : mCallbacksAlwaysAwaitingSuccess) { + callback.onUploadSucceeded(succeededUploadInfo); + } + } + + private void notifyPermanentFailureListeners(@NonNull PermanentlyFailedUploadInfo failedUploadInfo) { + for (UploadFailedPermanentlyCallback callback : mCallbacksAlwaysAwaitingFailure) { + callback.onUploadFailed(failedUploadInfo); + } + } + + private Collection updateStateForWorkInfos(@NonNull List workInfos) { + // To our succeededUploads and permanentlyFailedUploads we add entries - existing entries will + // exist in memory in this client. so we know that jobs 1, 3 and 5 succeeded this time, + // even after they cease to be in the list of work infos + // but they will not be persisted outside the life of the application (otherwise we'd run out of space) + // next time we launch, jobs 1, 3 and 5 are forgotten + Map succeededUploads = new HashMap<>(); + if (mLastUpdatedUploadState != null) { + succeededUploads.putAll(mLastUpdatedUploadState.uploadsSucceeded); + } + Map permanentlyFailedUploads = new HashMap<>(); + if (mLastUpdatedUploadState != null) { + permanentlyFailedUploads.putAll(mLastUpdatedUploadState.uploadsFailed); + } + + // for the pending uploads, the latest list of workInfos from WorkManager is always the source of truth, we will replace them each time + Map currentlyPendingUploads = new HashMap<>(); + + // as we process the work infos, we will keep track of the ids of jobs that have succeeded of failed permanently. + // When done we'll remove stored info for these jobs, to free memory space + Collection succeededOrPermanentlyFailedUploadIds = new ArrayList<>(); + + for (WorkInfo workInfo : workInfos) { + UploadInfo uploadInfo = fromWorkInfo(workInfo); + if (uploadInfo != null) { + if (uploadInfo instanceof SucceededUploadInfo) { + succeededUploads.put(uploadInfo.id, (SucceededUploadInfo) uploadInfo); + // keep track of the ids of completed jobs, we will delete them later + succeededOrPermanentlyFailedUploadIds.add(uploadInfo.id); + } else if (uploadInfo instanceof PendingUploadInfo) { + currentlyPendingUploads.put(uploadInfo.id, (PendingUploadInfo) uploadInfo); + } else if (uploadInfo instanceof PermanentlyFailedUploadInfo) { + permanentlyFailedUploads.put(uploadInfo.id, (PermanentlyFailedUploadInfo) uploadInfo); + // keep track of the ids of completed jobs, we will delete them later + succeededOrPermanentlyFailedUploadIds.add(uploadInfo.id); + } + } + } + mLastUpdatedUploadState = new UploadStateInfo(succeededUploads, currentlyPendingUploads, permanentlyFailedUploads); + + return succeededOrPermanentlyFailedUploadIds; + } + + @Nullable + // null if the work info is missing what we need, or we are in a bad state with no auxiliary data stored + private UploadInfo fromWorkInfo(@NonNull WorkInfo workInfo) { + String id = idFromWorkInfo(workInfo); + if (id == null) { + Log.e(LOG_TAG, "ignoring worker with bad state, missing id tag " + workInfo.getId()); + return null; + } + + Map metadata = BetweenWorkDataStore.get(mContext).getData(id); + if (metadata == null) { + // ignoring worker with bad state: stored metadata is missing or malformed. + // this happens often when the succeeded work info continues to be returned from the + // work info query after we have cleared its data + return null; + } + + Double progress = workInfo.getProgress().getDouble(TusUploadWorker.UPLOAD_PROGRESS_DOUBLE, 0); + + TusUploadWorker.FailureReason failureReason = null; + String failureDetails = null; + Map data = BetweenWorkDataStore.get(mContext).getData(TusUploadWorker.KEY_WORKER_ID_PREFIX + workInfo.getId()); + if (data != null && data.containsKey(TusUploadWorker.KEY_STATUS)) { + if (TusUploadWorker.STATUS_FAILED_NO_RETRY.equals(data.get(TusUploadWorker.KEY_STATUS)) || TusUploadWorker.STATUS_FAILED_WILL_RETRY.equals(data.get(TusUploadWorker.KEY_STATUS))) { + String reasonEnumOrdinal = data.get(TusUploadWorker.KEY_FAILURE_REASON_ENUM); + if (reasonEnumOrdinal == null) { + Log.e(LOG_TAG, "worker with bad state: stored data is missing or malformed. id:" + workInfo.getId()); + return null; + } + failureReason = TusUploadWorker.FailureReason.values()[Integer.parseInt(reasonEnumOrdinal)]; + failureDetails = data.get(TusUploadWorker.KEY_FAILURE_DETAILS); + } + } + + switch (workInfo.getState()) { + case ENQUEUED: + return new PendingUploadInfo(id, PendingUploadInfo.State.SCHEDULED, metadata, progress, failureReason, failureDetails); + case RUNNING: + return new PendingUploadInfo(id, PendingUploadInfo.State.RUNNING, metadata, progress, failureReason, failureDetails); + case SUCCEEDED: + return new SucceededUploadInfo(id, metadata); + case FAILED: + return new PermanentlyFailedUploadInfo(id, metadata, failureReason, failureDetails); + case BLOCKED: + // unexpected, should never happen as we filter out this state + // (and because the upload work is never blocked by any other work) + Log.e(LOG_TAG, "worker in unexpected state: " + workInfo.getId() + " state: " + workInfo.getState()); + return null; + case CANCELLED: + // unexpected, should never happen as we do not cancel workers + Log.e(LOG_TAG, "worker in unexpected state: " + workInfo.getId() + " state: " + workInfo.getState()); + return null; + default: + // unexpected, covered all states above + Log.e(LOG_TAG, "worker in unexpected state: " + workInfo.getId() + " state: " + workInfo.getState()); + return null; + } + } + + @Nullable // null if the WorkInfo was not created correctly for us to get our id + private static String idFromWorkInfo(@NonNull WorkInfo workInfo) { + for (String tag : workInfo.getTags()) { + if (tag.startsWith(TUS_UPLOAD_ID_TAG_PREFIX)) { + return tag.substring(TUS_UPLOAD_ID_TAG_PREFIX.length()); + } + } + return null; + } + + public void getPendingUploadInfo(@NonNull UploadInfoChangedCallback callback) { + Objects.requireNonNull(callback, "info may not be available immediately, must specify callback"); + + boolean notifyInstead = true; + synchronized (mWorkInfoUpdateLock) { + if (mLastUpdatedUploadState == null) { + mCallbacksAwaitingOneTimeInfo.add(callback); + notifyInstead = false; + } + } + + if (notifyInstead) { + callback.onUpdatedUploadInfoAvailable(mLastUpdatedUploadState); + } + } + + /** + * @param callback to receive updates about changes to scheduled or in progress uploads. This will be called immediately if we have any information about the current state of pending uploads. + */ + public void addPendingUploadChangeListener(@NonNull UploadInfoChangedCallback callback) { + Objects.requireNonNull(callback, "must specify callback"); + if (mLastUpdatedUploadState != null) { + callback.onUpdatedUploadInfoAvailable(mLastUpdatedUploadState); + } + mCallbacksAlwaysAwaitingInfo.add(callback); + } + + public void removePendingUploadChangeListener(@NonNull UploadInfoChangedCallback callback) { + Objects.requireNonNull(callback, "must specify callback"); + mCallbacksAlwaysAwaitingInfo.remove(callback); + } + + /** + * @param callback will be notified of the next and all subsequent successful uploads, but not of anything that succeeded previously. + */ + public void addUploadSuccessListener(@NonNull UploadSucceededCallback callback) { + Objects.requireNonNull(callback, "must specify callback"); + mCallbacksAlwaysAwaitingSuccess.add(callback); + } + + public void removeUploadSuccessListener(@NonNull UploadSucceededCallback callback) { + Objects.requireNonNull(callback, "must specify callback"); + mCallbacksAlwaysAwaitingSuccess.remove(callback); + } + + /** + * @param callback will be notified of the next and all subsequent permanently failed uploads, but not of anything that failed previously. + */ + public void addUploadFailureListener(@NonNull UploadFailedPermanentlyCallback callback) { + Objects.requireNonNull(callback, "must specify callback"); + mCallbacksAlwaysAwaitingFailure.add(callback); + } + + public void removeUploadFailureListener(@NonNull UploadFailedPermanentlyCallback callback) { + Objects.requireNonNull(callback, "must specify callback"); + mCallbacksAlwaysAwaitingFailure.remove(callback); + } + + /** + * Submits the file for upload with the default submission policy (any network, retries for 48 hrs, exponential backoff). + * File is copied and stored locally until the upload succeeds. + * @throws FileSubmissionException if there is a problem copying or storing the file + */ + @WorkerThread + public String submitFileForUpload(@NonNull Uri fileUri) throws FileSubmissionException { + return submitFileForUpload(fileUri, null, null, null); + } + + /** + * Submits the file for upload. File is copied and stored locally until the upload succeeds. + * @param submissionPolicy controls number of retries, retry interval, and whether we limit uploads to unmetered networks + * @throws FileSubmissionException if there is a problem copying or storing the file + */ + @WorkerThread + public String submitFileForUpload(@NonNull Uri fileUri, @Nullable SubmissionPolicy submissionPolicy) throws FileSubmissionException { + return submitFileForUpload(fileUri, null, null, submissionPolicy); + } + + /** + * Submits the file for upload. File is copied and stored locally until the upload succeeds. + * @param metadata to accompany the upload + * @param customHeaders to accompany the upload + * @param submissionPolicy controls number of retries, retry interval, and whether we limit uploads to unmetered networks + * @throws FileSubmissionException if there is a problem copying or storing the file + */ + @WorkerThread + public String submitFileForUpload(@NonNull Uri fileUri, @Nullable Map metadata, @Nullable Map customHeaders, @Nullable SubmissionPolicy submissionPolicy) throws FileSubmissionException { + Objects.requireNonNull(fileUri, "file uri is required"); + InputStream fileInputStream; + try { + fileInputStream = mContext.getContentResolver().openInputStream(fileUri); + } catch (FileNotFoundException e) { + throw new FileSubmissionException("unable to copy file, file not found", e); + } + return submitFileForUpload(fileInputStream, metadata, customHeaders, submissionPolicy); + } + + /** + * Submits data in the given input stream for upload using the default submission policy (any network, retries for 48 hrs, exponential backoff). Data is copied and stored locally until the upload succeeds. + * @throws FileSubmissionException if there is a problem copying or storing the file + */ + @WorkerThread + public String submitFileForUpload(@NonNull InputStream fileInput) throws FileSubmissionException { + return submitFileForUpload(fileInput, null, null, null); + } + + /** + * Submits data in the given input stream for upload. Data is copied and stored locally until the upload succeeds. + * @param submissionPolicy controls number of retries, retry interval, and whether we limit uploads to unmetered networks + * @throws FileSubmissionException if there is a problem copying or storing the file + */ + @WorkerThread + public String submitFileForUpload(@NonNull InputStream fileInput, @Nullable SubmissionPolicy submissionPolicy) throws FileSubmissionException { + return submitFileForUpload(fileInput, null, null, submissionPolicy); + } + + /** + * Submits data in the given input stream for upload. Data is copied and stored locally until the upload succeeds. + * @param metadata to accompany the upload + * @param customHeaders to accompany the upload + * @param submissionPolicy controls number of retries, retry interval, and whether we limit uploads to unmetered networks + * @throws FileSubmissionException if there is a problem copying or storing the file + */ + @WorkerThread + public String submitFileForUpload(@NonNull InputStream fileInput, @Nullable Map metadata, @Nullable Map customHeaders, @Nullable SubmissionPolicy submissionPolicy) throws FileSubmissionException { + Objects.requireNonNull(fileInput, "input stream is required"); + metadata = metadata == null ? Collections.emptyMap() : metadata; + customHeaders = customHeaders == null ? Collections.emptyMap() : customHeaders; + submissionPolicy = submissionPolicy == null ? new SubmissionPolicy.Builder().build() : submissionPolicy; + + try { + if (!mStorageDirectory.exists()) { + try { + if (!mStorageDirectory.mkdirs()) { + throw new FileSubmissionException("unable to create storage directory"); + } + } catch (SecurityException e) { + throw new FileSubmissionException("no permission to access storage directory", e); + } + } + String fileName = UUID.randomUUID().toString(); + File file = new File(mStorageDirectory, fileName); + try (OutputStream output = new FileOutputStream(file)) { + byte[] buffer = new byte[4 * CHUNK_SIZE]; + int read; + while ((read = fileInput.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + output.flush(); + } + + // we have it now at file, try to upload it + return submitForUpload(fileName, file, metadata, customHeaders, submissionPolicy); + } catch (IOException e) { + throw new FileSubmissionException("unable to copy file", e); + } finally { + try { + fileInput.close(); + } catch (IOException ignored) { + } + } + } + + private String submitForUpload(@NonNull String uniqueId, @NonNull File file, @NonNull Map metadata, @NonNull Map customHeaders, @NonNull SubmissionPolicy submissionPolicy) { + Constraints uploadTaskConstraints = new Constraints.Builder() + .setRequiredNetworkType(submissionPolicy.uploadCriteria.equals(SubmissionPolicy.UploadCriteria.UNMETERED_ONLY) ? NetworkType.UNMETERED : NetworkType.CONNECTED) + .build(); + + OneTimeWorkRequest uploadFile = + new OneTimeWorkRequest.Builder(TusUploadWorker.class) + .setInputData(TusUploadWorker.createInputData(mUploadUri, file, metadata, customHeaders, submissionPolicy)) + .setConstraints(uploadTaskConstraints) + .addTag(TUS_UPLOAD_TAG) + .addTag(TUS_UPLOAD_ID_TAG_PREFIX + uniqueId) + .build(); + + OneTimeWorkRequest deleteFile = + new OneTimeWorkRequest.Builder(FileCleanupWorker.class) + .setInputData(FileCleanupWorker.createInputData(file)) + .addTag(DELETE_FILE_TAG) + .build(); + + BetweenWorkDataStore.get(mContext).storeData(uniqueId, metadata); + WorkManager.getInstance(mContext).beginUniqueWork(uniqueId, ExistingWorkPolicy.REPLACE, uploadFile).then(deleteFile).enqueue(); + return uniqueId; + } + + public static class FileSubmissionException extends Exception { + public FileSubmissionException(String message) { + super(message); + } + + public FileSubmissionException(Exception cause) { + super(cause); + } + + public FileSubmissionException(String message, Exception cause) { + super(message, cause); + } + } + + public static class SubmissionPolicy { + public enum UploadCriteria { + ANY_NETWORK, + UNMETERED_ONLY + } + + public enum BackoffType { + LINEAR, + EXPONENTIAL + } + + + public final UploadCriteria uploadCriteria; + public final long backoffIntervalMillis; + public final BackoffType backoffType; + @Nullable // null indicates no stop-by date + public final DateTime stopDate; + @Nullable // null indicates no cap on retries + public final Integer maxRetries; + + public static class Builder { + private UploadCriteria mUploadCriteria = UploadCriteria.ANY_NETWORK; + private long mBackoffIntervalMillis = WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS; + private BackoffType mBackoffType = BackoffType.EXPONENTIAL; + public DateTime mStopDate = DateTime.now().plusHours(48); + public Integer mMaxRetries = null; + + public Builder withUploadCriteria(@NonNull UploadCriteria uploadCriteria) { + mUploadCriteria = uploadCriteria; + return this; + } + + public Builder withBackoffCriteria(int intervalMillis, @NonNull BackoffType type) { + if (intervalMillis <= 0) { + throw new IllegalArgumentException("interval must be > 0"); + } + mBackoffIntervalMillis = intervalMillis; + mBackoffType = type; + return this; + } + + public Builder withInfiniteRetries() { + mStopDate = null; + mMaxRetries = null; + return this; + } + + public Builder withMaximumRetries(int maximumRetries) { + mStopDate = null; + mMaxRetries = maximumRetries; + return this; + } + + public Builder withStopDate(@NonNull DateTime stopDate) { + mStopDate = stopDate; + mMaxRetries = null; + return this; + } + + public SubmissionPolicy build() { + return new SubmissionPolicy(this); + } + } + + private SubmissionPolicy(@NonNull Builder builder) { + uploadCriteria = builder.mUploadCriteria; + backoffIntervalMillis = builder.mBackoffIntervalMillis; + backoffType = builder.mBackoffType; + maxRetries = builder.mMaxRetries; + stopDate = builder.mStopDate; + } + } +} \ No newline at end of file diff --git a/tus-android-client/src/main/java/io/tus/android/client/TusAndroidUpload.java b/tus-android-client/src/main/java/io/tus/android/client/TusAndroidUpload.java deleted file mode 100644 index 4a626dc..0000000 --- a/tus-android-client/src/main/java/io/tus/android/client/TusAndroidUpload.java +++ /dev/null @@ -1,59 +0,0 @@ -package io.tus.android.client; - -import android.content.ContentResolver; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.provider.OpenableColumns; -import android.util.Log; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -import io.tus.java.client.TusUpload; - -public class TusAndroidUpload extends TusUpload { - public TusAndroidUpload(Uri uri, Context context) throws FileNotFoundException { - ContentResolver resolver = context.getContentResolver(); - Cursor cursor = resolver.query(uri, new String[]{OpenableColumns.SIZE, OpenableColumns.DISPLAY_NAME}, null, null, null); - if(cursor == null) { - throw new FileNotFoundException(); - } - - int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - cursor.moveToFirst(); - String name = cursor.getString(nameIndex); - - // On some files, ContentResolver#query will report the wrong filesize - // even though the InputStream reads the correct length. This discrepancy - // causes mismatching upload offsets when the upload should be finished. - // Using the stat size from a file descriptor seems to always report the - // correct filesize. - // See https://github.com/tus/tus-android-client/issues/25 - // See https://stackoverflow.com/questions/21882322/how-to-correctly-get-the-file-size-of-an-android-net-uri - ParcelFileDescriptor fd = resolver.openFileDescriptor(uri, "r"); - if(fd == null) { - throw new FileNotFoundException(); - } - long size = fd.getStatSize(); - try { - fd.close(); - } catch (IOException e) { - Log.e("TusAndroidUpload", "unable to close ParcelFileDescriptor", e); - } - - setSize(size); - setInputStream(resolver.openInputStream(uri)); - - setFingerprint(String.format("%s-%d", uri.toString(), size)); - - Map metadata = new HashMap<>(); - metadata.put("filename", name); - setMetadata(metadata); - - cursor.close(); - } -} diff --git a/tus-android-client/src/main/java/io/tus/android/client/TusPreferencesURLStore.java b/tus-android-client/src/main/java/io/tus/android/client/TusPreferencesURLStore.java index eda9c0a..28d0cc4 100644 --- a/tus-android-client/src/main/java/io/tus/android/client/TusPreferencesURLStore.java +++ b/tus-android-client/src/main/java/io/tus/android/client/TusPreferencesURLStore.java @@ -3,68 +3,57 @@ import android.content.SharedPreferences; import android.os.Build; +import androidx.annotation.NonNull; + import java.net.MalformedURLException; import java.net.URL; +import java.util.Objects; import io.tus.java.client.TusURLStore; public class TusPreferencesURLStore implements TusURLStore { - private SharedPreferences preferences; + private final SharedPreferences preferences; - public TusPreferencesURLStore(SharedPreferences preferences) { + public TusPreferencesURLStore(@NonNull SharedPreferences preferences) { + Objects.requireNonNull(preferences, "must specify SharedPreferences"); this.preferences = preferences; } public URL get(String fingerprint) { // Ignore empty fingerprints - if(fingerprint.length() == 0) { + if(fingerprint == null || fingerprint.isEmpty()) { return null; } String urlStr = preferences.getString(fingerprint, ""); // No entry was found - if(urlStr.length() == 0) { + if (urlStr.isEmpty()) { return null; } // Ignore invalid URLs try { return new URL(urlStr); - } catch(MalformedURLException e) { + } catch (MalformedURLException e) { remove(fingerprint); return null; } } public void set(String fingerprint, URL url) { - String urlStr = url.toString(); - // Ignore empty fingerprints - if(fingerprint.length() == 0) { + if (url == null || fingerprint == null || fingerprint.isEmpty()) { return; } - - SharedPreferences.Editor editor = preferences.edit(); - editor.putString(fingerprint, urlStr); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) { - editor.apply(); - } else { - editor.commit(); - } + preferences.edit().putString(fingerprint, url.toString()).apply(); } public void remove(String fingerprint) { // Ignore empty fingerprints - if(fingerprint.length() == 0) { + if (fingerprint == null || fingerprint.isEmpty()) { return; } - SharedPreferences.Editor editor = preferences.edit(); - editor.remove(fingerprint); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) { - editor.apply(); - } else { - editor.commit(); - } + preferences.edit().remove(fingerprint).apply(); } } diff --git a/tus-android-client/src/main/java/io/tus/android/client/TusUploadWorker.java b/tus-android-client/src/main/java/io/tus/android/client/TusUploadWorker.java new file mode 100644 index 0000000..8ed7f4c --- /dev/null +++ b/tus-android-client/src/main/java/io/tus/android/client/TusUploadWorker.java @@ -0,0 +1,235 @@ +package io.tus.android.client; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.work.Data; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import org.joda.time.DateTime; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import io.tus.java.client.ProtocolException; +import io.tus.java.client.TusClient; +import io.tus.java.client.TusUpload; +import io.tus.java.client.TusUploader; + +/** + * This worker performs the file upload to a TUS server. + */ +public class TusUploadWorker extends Worker { + + // OUTPUT: Progress + public static final String UPLOAD_PROGRESS_DOUBLE = "UPLOAD_PROGRESS"; + // OUTPUT: Failure + public static final String FAILURE_REASON_ENUM = "FAILURE_REASON_ENUM"; + public static final String FAILURE_DETAIL_MESSAGE = "FAILURE_DETAIL_MESSAGE"; + + // INPUT + private static final String PATH_TO_FILE_TO_UPLOAD = "PATH_TO_FILE_TO_UPLOAD"; + private static final String FILE_UPLOAD_URL = "FILE_UPLOAD_URL"; + private static final String METADATA_KEYS_ARRAY = "METADATA_KEYS_ARRAY"; + private static final String METADATA_VALUES_ARRAY = "METADATA_VALUES_ARRAY"; + private static final String CUSTOM_HEADERS_KEYS_ARRAY = "CUSTOM_HEADERS_KEYS_ARRAY"; + private static final String CUSTOM_HEADERS_VALUES_ARRAY = "CUSTOM_HEADERS_VALUES_ARRAY"; + private static final String DATE_TO_STOP_ISO8601 = "DATE_TO_STOP"; + private static final String MAX_RETRIES = "MAX_RETRIES"; + + // stored data + public static final String KEY_WORKER_ID_PREFIX = "worker-id-"; + public static final String KEY_STATUS = "key_status"; + // note: no 'success' status because we delete stored data when succeeds + public static final String STATUS_STARTED = "status_started"; + public static final String STATUS_STOPPED = "status_stopped"; + public static final String STATUS_FAILED_WILL_RETRY = "status_failed_will_retry"; + public static final String STATUS_FAILED_NO_RETRY = "status_failed_no_retry"; + public static final String KEY_FAILURE_REASON_ENUM = "key_failure_reason"; + public static final String KEY_FAILURE_DETAILS = "key_failure_details"; + + private static final int CHUNK_SIZE = 1024; + + public enum FailureReason { + // important: these enums are sent & retrieved by their ordinal number. DO NOT CHANGE THE ORDER + ILLEGAL_ARGUMENT, + UPLOAD_FILE_NOT_FOUND, + RECOVERABLE_PROTOCOL_ERROR, + UNRECOVERABLE_PROTOCOL_ERROR, + IO_ERROR + } + + public static Data createInputData(@NonNull Uri uploadUri, @NonNull File file, @NonNull Map metadata, @NonNull Map customHeaders, @NonNull TusAndroidClient.SubmissionPolicy submissionPolicy) { + Data.Builder builder = new Data.Builder(); + if (submissionPolicy.stopDate != null) { + builder.putString(TusUploadWorker.DATE_TO_STOP_ISO8601, submissionPolicy.stopDate.toString()); + } + if (submissionPolicy.maxRetries != null) { + builder.putInt(TusUploadWorker.MAX_RETRIES, submissionPolicy.maxRetries); + } + builder.putString(TusUploadWorker.FILE_UPLOAD_URL, uploadUri.toString()); + builder.putString(TusUploadWorker.PATH_TO_FILE_TO_UPLOAD, file.getPath()); + builder.putStringArray(TusUploadWorker.CUSTOM_HEADERS_KEYS_ARRAY, customHeaders.keySet().toArray(new String[0])); + builder.putStringArray(TusUploadWorker.CUSTOM_HEADERS_VALUES_ARRAY, customHeaders.values().toArray(new String[0])); + builder.putStringArray(TusUploadWorker.METADATA_KEYS_ARRAY, metadata.keySet().toArray(new String[0])); + builder.putStringArray(TusUploadWorker.METADATA_VALUES_ARRAY, metadata.values().toArray(new String[0])); + return builder.build(); + } + + public TusUploadWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + recordProgress(0); + BetweenWorkDataStore.get(context).storeData(KEY_WORKER_ID_PREFIX + getId(), Collections.singletonMap(KEY_STATUS, STATUS_STARTED)); + } + + @NonNull + @Override + public Result doWork() { + URL fileUploadUrl; + try { + fileUploadUrl = new URL(getInputData().getString(FILE_UPLOAD_URL)); + } catch (MalformedURLException e) { + return recordWorkAborted(false, FailureReason.ILLEGAL_ARGUMENT, "invalid upload url: " + e.getMessage()); + } + + String fileLocation = getInputData().getString(PATH_TO_FILE_TO_UPLOAD); + if (fileLocation == null) { + return recordWorkAborted(false, FailureReason.ILLEGAL_ARGUMENT, "file path must be specified"); + } + String fileLocationPath = Uri.parse(fileLocation).getPath(); + if (fileLocationPath == null) { + return recordWorkAborted(false, FailureReason.ILLEGAL_ARGUMENT, "file path must be valid"); + } + + DateTime retryLimit = null; + String limit = getInputData().getString(DATE_TO_STOP_ISO8601); + if (limit != null) { + try { + retryLimit = DateTime.parse(limit); + } catch (IllegalArgumentException e) { + return recordWorkAborted(false, FailureReason.ILLEGAL_ARGUMENT, "malformed retry limit time: " + limit); + } + } + Integer maxRetryCount = null; + int retries = getInputData().getInt(MAX_RETRIES, -1); + if (retries != -1) { + maxRetryCount = retries; + } + + Map uploadMetadata; + try { + String[] metadataKeys = getInputData().getStringArray(METADATA_KEYS_ARRAY); + String[] metadataValues = getInputData().getStringArray(METADATA_VALUES_ARRAY); + uploadMetadata = reconstructMap(metadataKeys, metadataValues); + } catch (IllegalArgumentException e) { + return recordWorkAborted(false, FailureReason.ILLEGAL_ARGUMENT, "malformed metadata: " + e.getMessage()); + } + + Map uploadCustomHeaders; + try { + String[] headerKeys = getInputData().getStringArray(CUSTOM_HEADERS_KEYS_ARRAY); + String[] headerValues = getInputData().getStringArray(CUSTOM_HEADERS_VALUES_ARRAY); + uploadCustomHeaders = reconstructMap(headerKeys, headerValues); + } catch (IllegalArgumentException e) { + return recordWorkAborted(false, FailureReason.ILLEGAL_ARGUMENT, "malformed headers: " + e.getMessage()); + } + + TusClient tusClient = new TusClient(); + tusClient.setUploadCreationURL(fileUploadUrl); + tusClient.enableResuming(new TusPreferencesURLStore(getApplicationContext().getSharedPreferences(TusAndroidClient.TUS_SHARED_PREFS_NAME, Context.MODE_PRIVATE))); + tusClient.setHeaders(uploadCustomHeaders); + + TusUpload upload; + try { + upload = new TusUpload(new File(fileLocationPath)); + } catch (FileNotFoundException e) { + return recordWorkAborted(false, FailureReason.UPLOAD_FILE_NOT_FOUND, e.getMessage()); + } + + upload.setMetadata(uploadMetadata); + + try { + TusUploader uploader = tusClient.resumeOrCreateUpload(upload); + uploader.setChunkSize(CHUNK_SIZE); + + do { + long totalBytes = upload.getSize(); + long bytesUploaded = uploader.getOffset(); + float progress = (float) bytesUploaded / totalBytes * 100; + + recordProgress(progress); + + if (isStopped()) { + uploader.finish(); + BetweenWorkDataStore.get(getApplicationContext()).storeData(KEY_WORKER_ID_PREFIX + getId(), Collections.singletonMap(KEY_STATUS, STATUS_STOPPED)); + return null; // result is ignored if we were stopped by the system + } + } while (uploader.uploadChunk() > -1); + + uploader.finish(); + BetweenWorkDataStore.get(getApplicationContext()).clearData(KEY_WORKER_ID_PREFIX + getId()); + return Result.success(); + } catch (ProtocolException e) { + if (e.shouldRetry()) { + return recordWorkAborted(eligibleToRetry(retryLimit, maxRetryCount), FailureReason.RECOVERABLE_PROTOCOL_ERROR, e.getMessage()); + } + return recordWorkAborted(false, FailureReason.UNRECOVERABLE_PROTOCOL_ERROR, e.getMessage()); + } catch (IOException e) { + return recordWorkAborted(eligibleToRetry(retryLimit, maxRetryCount), FailureReason.IO_ERROR, e.getMessage()); + } + } + + private boolean eligibleToRetry(@Nullable DateTime retryLimit, @Nullable Integer maxRetryCount) { + return (retryLimit == null || DateTime.now().isBefore(retryLimit)) && (maxRetryCount == null || maxRetryCount > getRunAttemptCount()); + } + + private void recordProgress(float progress) { + setProgressAsync(new Data.Builder() + .putDouble(UPLOAD_PROGRESS_DOUBLE, progress).build()); + } + + private Result recordWorkAborted(boolean willRetry, @NonNull FailureReason failureReason, @Nullable String failureDetails) { + Map data = new HashMap<>(); + data.put(KEY_STATUS, willRetry ? STATUS_FAILED_WILL_RETRY : STATUS_FAILED_NO_RETRY); + data.put(KEY_FAILURE_REASON_ENUM, String.valueOf(failureReason.ordinal())); + + Data.Builder dataBuilder = new Data.Builder() + .putInt(FAILURE_REASON_ENUM, failureReason.ordinal()); + + if (failureDetails != null) { + data.put(KEY_FAILURE_DETAILS, failureDetails); + dataBuilder.putString(FAILURE_DETAIL_MESSAGE, failureDetails); + } + + BetweenWorkDataStore.get(getApplicationContext()).storeData(KEY_WORKER_ID_PREFIX + getId(), data); + return willRetry ? Result.retry() : Result.failure(dataBuilder.build()); + } + + @NonNull + private static Map reconstructMap(@Nullable String[] keys, @Nullable String[] values) { + if (keys == null && values == null) { + return Collections.emptyMap(); + } + if (keys == null) { + throw new IllegalArgumentException("missing keys, provided values"); + } else if (values == null) { + throw new IllegalArgumentException("missing values, provided keys"); + } else if (keys.length != values.length) { + throw new IllegalArgumentException("array miss-match: " + keys.length + " keys but " + values.length + " values"); + } + Map map = new HashMap<>(keys.length); + for (int i = 0; i < keys.length; ++i) { + map.put(keys[i], values[i]); + } + return map; + } +} diff --git a/tus-android-client/src/test/java/io/tus/android/client/GsonUtilsTest.java b/tus-android-client/src/test/java/io/tus/android/client/GsonUtilsTest.java new file mode 100644 index 0000000..f27810b --- /dev/null +++ b/tus-android-client/src/test/java/io/tus/android/client/GsonUtilsTest.java @@ -0,0 +1,25 @@ +package io.tus.android.client; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +public class GsonUtilsTest { + + @Test + public void preservesMapsOfStrings() { + Map myMap = new HashMap<>(); + myMap.put("foo", "bar"); + myMap.put("my_url", "http://something.com?foo=bar"); + + String marshalled = GsonUtils.GsonMarshaller.getInstance().marshal(myMap); + Map result = GsonUtils.GsonUnmarshaller.create(Map.class).unmarshal(marshalled); + assertThat(result.size(), is(2)); + assertThat(result.get("foo"), is("bar")); + assertThat(result.get("my_url"), is("http://something.com?foo=bar")); + } +} \ No newline at end of file diff --git a/tus-android-client/src/test/java/io/tus/android/client/TusPreferencesURLStoreTest.java b/tus-android-client/src/test/java/io/tus/android/client/TusPreferencesURLStoreTest.java index d31c79d..4c72772 100644 --- a/tus-android-client/src/test/java/io/tus/android/client/TusPreferencesURLStoreTest.java +++ b/tus-android-client/src/test/java/io/tus/android/client/TusPreferencesURLStoreTest.java @@ -1,5 +1,7 @@ package io.tus.android.client; +import static org.junit.Assert.assertEquals; + import android.app.Activity; import org.junit.Test; @@ -9,11 +11,14 @@ import java.net.URL; -import static org.junit.Assert.assertEquals; - @RunWith(RobolectricTestRunner.class) public class TusPreferencesURLStoreTest { + @Test(expected = NullPointerException.class) + public void requiresSharedPreferences() { + new TusPreferencesURLStore(null); + } + @Test public void shouldSetGetAndDeleteURLs() throws Exception { Activity activity = Robolectric.setupActivity(Activity.class);