diff --git a/build.gradle b/build.gradle index 3dc52d0e..a4780412 100644 --- a/build.gradle +++ b/build.gradle @@ -7,5 +7,5 @@ plugins { id 'com.android.application' version '7.4.2' apply false id 'com.android.library' version '7.4.2' apply false id 'org.jetbrains.kotlin.android' version '1.8.21' apply false - id 'com.mux.gradle.android.mux-android-distribution' version '1.0.3' apply false -} \ No newline at end of file + id 'com.mux.gradle.android.mux-android-distribution' version '1.1.0' apply false +} diff --git a/library/Module.md b/library/Module.md new file mode 100644 index 00000000..e340b302 --- /dev/null +++ b/library/Module.md @@ -0,0 +1,54 @@ +# Module Mux Upload SDK + +The Mux Upload SDK processes and uploads video files to [Mux Video](https://www.mux.com/video) from +a user's local device. It is part of a full-stack flow described in our +guide, [Upload Files Directly](https://docs.mux.com/guides/video/upload-files-directly). + +Once you have your direct upload URL, you can use it to upload a file using this SDK. + +## Initializing the SDK + +This SDK must be initialized once with a `Context` before it can be used + +```kotlin +// from your custom Application class, Activity, etc. The context isn't saved +MuxUploadSdk.initialize(appContext = this) +``` + +## Starting a new upload + +The `MuxUpload` class can be used to start a video upload and observe its progress. + +```kotlin + // Start a new upload +val upload = MuxUpload.Builder(myUploadUrl, myInputFile).build() +upload.setResultListener { /*...*/ } +upload.setProgressListener { /*...*/ } +upload.start() +``` + +### Handling errors + +The upload SDK handles transient errors according to a customizable retry policy. Fatal errors are +reported by `MuxUpload.setResultListener`. + +```kotlin +upload.setResultListener { result -> + if (!result.isSuccess) { + notifyError() + } else { + /*...*/ + } +} +``` + +## Resuming uploads after process death + +Uploads managed by this SDK can be resumed after process death, or if network connectivity caused +them to fail at some time in the past. + +```kotlin + MuxUploadManager.resumeAllCachedJobs() +val upload = MuxUploadManager.findUploadByFile(myVideoFile) +upload.setResultListener { /*...*/ } +``` diff --git a/library/build.gradle b/library/build.gradle index f3bfbaac..ca03fb46 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -38,7 +38,7 @@ muxDistribution { groupIds just("com.mux.video") publicReleaseIf releaseOnTag() - packageJavadocs = releaseOnTag().call() + packageJavadocs = true packageSources = true publishIf { it.containsIgnoreCase("release") } artifactoryConfig { @@ -46,10 +46,14 @@ muxDistribution { releaseRepoKey = 'default-maven-release-local' devRepoKey = 'default-maven-local' } + dokkaConfig { + moduleName = "Mux Upload SDK" + footer = "(c) " + new Date().format("yyyy") + " Mux, Inc. Have questions or need help?" + + " Contact support@mux.com" + } } dependencies { - implementation 'androidx.core:core-ktx:1.9.0' implementation "com.squareup.okhttp3:logging-interceptor:4.11.0" diff --git a/library/logo-icon.svg b/library/logo-icon.svg new file mode 100644 index 00000000..533a67a4 --- /dev/null +++ b/library/logo-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/library/src/main/java/com/mux/video/upload/MuxUploadSdk.kt b/library/src/main/java/com/mux/video/upload/MuxUploadSdk.kt index b57b5954..ede20dd2 100644 --- a/library/src/main/java/com/mux/video/upload/MuxUploadSdk.kt +++ b/library/src/main/java/com/mux/video/upload/MuxUploadSdk.kt @@ -2,6 +2,7 @@ package com.mux.video.upload import android.content.Context import android.util.Log +import com.mux.video.upload.MuxUploadSdk.initialize import com.mux.video.upload.api.MuxUploadManager import com.mux.video.upload.internal.UploadJobFactory import com.mux.video.upload.internal.UploadMetrics @@ -11,13 +12,13 @@ import okhttp3.logging.HttpLoggingInterceptor import java.util.concurrent.TimeUnit /** - * Uploads videos to Mux Video. + * This object allows you to get version info, enable logging, override the HTTP client, etc * - * TODO: This would be a good place to put usage + * Before using the SDK, you must call [initialize]. */ object MuxUploadSdk { /** - * The current version of this SDK. Release builds of this SDK follow semver (https://semver.org) + * The current version of the SDK. Release builds of this SDK follow semver (https://semver.org) */ @Suppress("unused") const val VERSION = BuildConfig.LIB_VERSION @@ -56,12 +57,19 @@ object MuxUploadSdk { .build() } - @Suppress("unused") @JvmOverloads + /** + * Initializes the SDK with the given Context. The Context instance isn't saved. + * + * @param appContext A Context for your app. The passed instance isn't saved + * @param resumeStoppedUploads If true, uploads that failed due to errors or process death will be automatically resumed + */ + @Suppress("unused") + @JvmOverloads fun initialize(appContext: Context, resumeStoppedUploads: Boolean = true) { initializeUploadPersistence(appContext) UploadMetrics.initialize(appContext) - if (resumeStoppedUploads) { + if (resumeStoppedUploads) { val upl = MuxUploadManager.resumeAllCachedJobs() } } diff --git a/library/src/main/java/com/mux/video/upload/api/MuxUpload.kt b/library/src/main/java/com/mux/video/upload/api/MuxUpload.kt index 965d9c9c..4a466ae5 100644 --- a/library/src/main/java/com/mux/video/upload/api/MuxUpload.kt +++ b/library/src/main/java/com/mux/video/upload/api/MuxUpload.kt @@ -10,14 +10,25 @@ import kotlinx.coroutines.* import java.io.File /** - * Represents a task that does a single direct upload to a Mux Video asset previously created. + * Represents an upload of a video as a Mux Video asset. In order to use this SDK, you must first + * create a [direct upload](https://docs.mux.com/guides/video/upload-files-directly) server-side, + * then return that direct upload PUT URL to your app. * - * TODO: Talk about creating the upload: https://docs.mux.com/api-reference/video#operation/create-direct-upload + * Once you have a PUT URL, you can create and [start] your upload using the [Builder] * - * This prototype does a single streamed PUT request for the whole file and delivers results, but - * the production version will have more sophisticated behavior. + * For example: + * ``` + * // Start a new upload + * val upload = MuxUpload.Builder(myUploadUrl, myInputFile).build() + * upload.setResultListener { myHandleResult(it) } + * upload.setProgressListener { myHandleProgress(it) } + * upload.start() + * ``` * - * Create an instance of this class with the [Builder] + * For full documentation on how to configure your upload, see the [Builder] + * + * @see Builder + * @see MuxUploadManager */ class MuxUpload private constructor( private var uploadInfo: UploadInfo, private val autoManage: Boolean = true @@ -29,7 +40,8 @@ class MuxUpload private constructor( val videoFile: File get() = uploadInfo.file /** - * The most-currents state of the upload + * The current state of the upload. To be notified of state updates, you can use + * [setProgressListener] and [setResultListener] */ val currentState: Progress get() = lastKnownState ?: uploadInfo.progressFlow?.replayCache?.firstOrNull() ?: Progress( @@ -48,7 +60,7 @@ class MuxUpload private constructor( private var _error: Exception? = null /** - * Returns true if the upload was successful + * True if the upload was successful, false otherwise */ val isSuccessful get() = _successful private var _successful: Boolean = false @@ -67,11 +79,17 @@ class MuxUpload private constructor( } /** - * Starts this Upload. The Upload will continue in the background *even if this object is - * destroyed*. + * Starts this Upload. You don't need to hold onto this object in order for the upload to + * complete, it will continue in parallel with the rest of your app. You can always get a handle + * to an ongoing upload by using [MuxUploadManager.findUploadByFile] + * * To suspend the execution of the upload, use [pause]. To cancel it completely, use [cancel] * * @param forceRestart Start the upload from the beginning even if the file is partially uploaded + * + * @see pause + * @see cancel + * @see MuxUploadManager */ @JvmOverloads fun start(forceRestart: Boolean = false) { @@ -88,41 +106,57 @@ class MuxUpload private constructor( // Get an updated UploadInfo with a job & event channels uploadInfo = if (autoManage) { // We may or may not get a fresh worker, depends on if the upload is already going - MuxUploadManager.startJob(uploadInfo, forceRestart) + /*uploadInfo =*/ MuxUploadManager.startJob(uploadInfo, forceRestart) } else { // If we're not managing the worker, the job is purely internal to this object - MuxUploadSdk.uploadJobFactory().createUploadJob(uploadInfo, coroutineScope) + /*uploadInfo =*/ MuxUploadSdk.uploadJobFactory().createUploadJob(uploadInfo, coroutineScope) } logger.i("MuxUpload", "started upload: ${uploadInfo.file}") maybeObserveUpload(uploadInfo) } + /** + * If the upload has not succeeded, this function will suspend until the upload completes and + * return the result + * + * If the upload had failed, it will be restarted and this function will suspend until it + * completes + * + * If the upload already succeeded, the old result will be returned immediately + */ @Throws @Suppress("unused") + @JvmSynthetic suspend fun awaitSuccess(): Result { - return coroutineScope { - startInner(coroutineScope = this) - uploadInfo.uploadJob?.let { job -> - val result = job.await() - result - } ?: Result.failure(Exception("Upload failed to start")) + val result = uploadInfo.successFlow?.replayCache?.firstOrNull() + return if (result != null) { + Result.success(result) // If we succeeded already, don't start again + } else { + coroutineScope { + startInner(coroutineScope = this) + uploadInfo.uploadJob?.let { job -> + val result = job.await() + result + } ?: Result.failure(Exception("Upload failed to start")) + } } } /** * Pauses the upload. If the upload was already paused, this method has no effect + * * You can resume the upload where it left off by calling [start] */ @Suppress("MemberVisibilityCanBePrivate") fun pause() { uploadInfo = if (autoManage) { observerJob?.cancel("user requested pause") - MuxUploadManager.pauseJob(uploadInfo) + /*uploadInfo =*/ MuxUploadManager.pauseJob(uploadInfo) } else { observerJob?.cancel("user requested pause") uploadInfo.uploadJob?.cancel() - uploadInfo.update( + /*uploadInfo =*/ uploadInfo.update( uploadJob = null, successFlow = null, errorFlow = null, @@ -148,7 +182,7 @@ class MuxUpload private constructor( } /** - * Adds a listener for progress updates on this upload + * Sets a listener for progress updates on this upload */ @MainThread fun setProgressListener(listener: UploadEventListener?) { @@ -157,7 +191,7 @@ class MuxUpload private constructor( } /** - * Adds a listener for success or failure updates on this upload + * Sets a listener for success or failure updates on this upload */ @MainThread fun setResultListener(listener: UploadEventListener>) { @@ -169,6 +203,9 @@ class MuxUpload private constructor( } } + /** + * Clears all listeners set on this object + */ @MainThread fun clearListeners() { resultListener = null @@ -181,8 +218,10 @@ class MuxUpload private constructor( upload.errorFlow?.let { flow -> launch { flow.collect { error -> - _error = error - resultListener?.onEvent(Result.failure(error)) + if (error !is CancellationException) { // Canceled uploads shouldn't generate events + _error = error + resultListener?.onEvent(Result.failure(error)) + } } } } @@ -222,12 +261,38 @@ class MuxUpload private constructor( ) /** - * Builds instances of [MuxUpload] + * Builds instances of [MuxUpload]. + * + * If you wish for fine-grained control over the upload process, some configuration is available. + * + * For example: + * ``` + * // Adapt to your upload to current network conditions + * val chunkSize = if (/* onWifi */) { + * 16 * 1024 * 1024 // 16M, bigger chunks go faster + * } else { + * 8 * 1024 * 1024 // 8M, smaller chunks are more reliable + * } + * + * val upload = MuxUpload.Builder(myUploadUrl, myInputFile) + * .chunkSize(chunkSize) // Mux's default is 8Mb + * .retriesPerChunk(5) // Mux's default is 3 + * .build() + * ``` * * @param uploadUri the URL obtained from the Direct video up + * @param videoFile a File that represents the video file you want to upload */ @Suppress("MemberVisibilityCanBePrivate") class Builder constructor(val uploadUri: Uri, val videoFile: File) { + + /** + * Create a new Builder with the specified input file and upload URL + * + * @param uploadUri the URL obtained from the Direct video up + * @param videoFile a File that represents the video file you want to upload + */ + @Suppress("unused") constructor(uploadUri: String, videoFile: File) : this(Uri.parse(uploadUri), videoFile) private var manageTask: Boolean = true @@ -244,33 +309,58 @@ class MuxUpload private constructor( errorFlow = null ) + /** + * Allow Mux to manage and remember the state of this upload + */ @Suppress("unused") fun manageUploadTask(autoManage: Boolean): Builder { manageTask = autoManage; return this } + /** + * The Upload SDK will upload your file in smaller chunks, which can be more reliable in adverse + * network conditions. + * + * @param sizeBytes The chunk size in bytes. Mux's default is 8M + */ @Suppress("unused") fun chunkSize(sizeBytes: Int): Builder { uploadInfo.update(chunkSize = sizeBytes) return this } + /** + * Allows you to opt out of Mux's performance analytics tracking. We track metrics related to + * the overall performance and reliability of your upload, in order to make our SDK better. + * + * If you would perfer not to share this information with us, you may opt out by passing `true` + * here. + */ @Suppress("unused") fun optOutOfEventTracking(optOut: Boolean) { uploadInfo.update(optOut = optOut) } + /** + * The Upload SDK will upload your file in smaller chunks, which can be more reliable in adverse + * network conditions. Each chunk can be retried individually, up to the given number of times + * + * @param retries The number of retries per chunk. Mux's default is 3 + */ @Suppress("unused") fun retriesPerChunk(retries: Int): Builder { uploadInfo.update(retriesPerChunk = retries) return this } + /** + * Creates a new [MuxUpload] with the given configuration. + */ fun build() = MuxUpload(uploadInfo) } - companion object { + internal companion object { @JvmSynthetic internal fun create(uploadInfo: UploadInfo) = MuxUpload(uploadInfo) } diff --git a/library/src/main/java/com/mux/video/upload/api/MuxUploadManager.kt b/library/src/main/java/com/mux/video/upload/api/MuxUploadManager.kt index 0008e9fe..6c6cfd26 100644 --- a/library/src/main/java/com/mux/video/upload/api/MuxUploadManager.kt +++ b/library/src/main/java/com/mux/video/upload/api/MuxUploadManager.kt @@ -1,12 +1,23 @@ package com.mux.video.upload.api -import android.util.Log import androidx.annotation.MainThread import com.mux.video.upload.MuxUploadSdk import com.mux.video.upload.internal.* import kotlinx.coroutines.* import java.io.File +/** + * Manages in-process uploads, allowing them to be observed from anywhere or restarted in case of + * network loss or process death + * + * To list all unfinished jobs, use [allUploadJobs] + * + * To find a job associated with a given file, use [findUploadByFile] + * + * To restart all uploads after process or network death, use [resumeAllCachedJobs]. + * + * @see MuxUpload + */ object MuxUploadManager { private val mainScope = MainScope() @@ -17,25 +28,28 @@ object MuxUploadManager { private val logger get() = MuxUploadSdk.logger /** - * Finds an in-progress or paused upload and returns an object to track it, if it was + * Finds an in-progress, paused, or failed upload and returns a [MuxUpload] to track it, if it was * in progress */ @Suppress("unused") + @MainThread fun findUploadByFile(videoFile: File): MuxUpload? = uploadsByFilename[videoFile.absolutePath]?.let { MuxUpload.create(it) } /** * Finds all in-progress or paused uploads and returns [MuxUpload] objects representing them. You * don't need to hold these specific instances except where they're locally used. The upload jobs - * will continue in parallel if they're auto-managed (see [MuxUpload.Builder.manageUploadTask]) + * will continue in parallel with the rest of your app */ @Suppress("unused") + @MainThread fun allUploadJobs(): List = uploadsByFilename.values.map { MuxUpload.create(it) } /** * Resumes any upload jobs that were prematurely stopped due to failures or process death. - * The jobs will all be resumed where they left off. Any jobs resumed this way will be returned + * The jobs will all be resumed where they left off. Any uploads resumed this way will be returned */ + @MainThread fun resumeAllCachedJobs(): List { return readAllCachedUploads() .onEach { uploadInfo -> startJob(uploadInfo, restart = false) } diff --git a/library/src/main/java/com/mux/video/upload/api/UploadEventListener.kt b/library/src/main/java/com/mux/video/upload/api/UploadEventListener.kt index a7d38bf8..69d780c3 100644 --- a/library/src/main/java/com/mux/video/upload/api/UploadEventListener.kt +++ b/library/src/main/java/com/mux/video/upload/api/UploadEventListener.kt @@ -4,5 +4,8 @@ package com.mux.video.upload.api * Listens for events from the Mux Upload SDK */ fun interface UploadEventListener { + /** + * Called when an event is generated + */ fun onEvent(event: EventType) } \ No newline at end of file diff --git a/library/src/main/java/com/mux/video/upload/internal/UploadMetrics.kt b/library/src/main/java/com/mux/video/upload/internal/UploadMetrics.kt index 7fb3e787..824bf705 100644 --- a/library/src/main/java/com/mux/video/upload/internal/UploadMetrics.kt +++ b/library/src/main/java/com/mux/video/upload/internal/UploadMetrics.kt @@ -16,7 +16,8 @@ import java.util.* internal class UploadMetrics private constructor() { - suspend fun reportUpload( + @JvmSynthetic + internal suspend fun reportUpload( startTimeMillis: Long, endTimeMillis: Long, uploadInfo: UploadInfo diff --git a/library/src/main/java/com/mux/video/upload/internal/network/CountingRequestBody.kt b/library/src/main/java/com/mux/video/upload/internal/network/CountingRequestBody.kt index 83e1a8a5..826501f3 100644 --- a/library/src/main/java/com/mux/video/upload/internal/network/CountingRequestBody.kt +++ b/library/src/main/java/com/mux/video/upload/internal/network/CountingRequestBody.kt @@ -4,6 +4,7 @@ import okhttp3.MediaType import okhttp3.RequestBody import okio.BufferedSink +@JvmSynthetic internal fun ByteArray.asCountingRequestBody( mediaType: MediaType?, contentLength: Long, @@ -15,6 +16,7 @@ internal fun ByteArray.asCountingRequestBody( callback = callback ) +@JvmSynthetic internal fun ByteArray.asCountingRequestBody( mediaType: MediaType?, contentLength: Long, diff --git a/settings.gradle b/settings.gradle index 8765c501..848ebb5f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,7 +11,7 @@ pluginManagement { } } dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral()