diff --git a/app/build.gradle b/app/build.gradle index 0c7be826..d60fac69 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -38,7 +38,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.5.9' + kotlinCompilerExtensionVersion '1.5.14' } packagingOptions { resources { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f0e072e7..e9b05fdf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,12 @@ + + + android:maxSdkVersion="28" /> + + + \ No newline at end of file diff --git a/app/src/main/java/com/mux/video/vod/demo/UploadExampleApp.kt b/app/src/main/java/com/mux/video/vod/demo/UploadExampleApp.kt index 828c7ea6..b603c1d4 100644 --- a/app/src/main/java/com/mux/video/vod/demo/UploadExampleApp.kt +++ b/app/src/main/java/com/mux/video/vod/demo/UploadExampleApp.kt @@ -1,11 +1,34 @@ package com.mux.video.vod.demo +import android.annotation.TargetApi import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.os.Build import com.mux.video.upload.MuxUploadSdk +import com.mux.video.upload.api.MuxUploadManager class UploadExampleApp : Application() { + override fun onCreate() { super.onCreate() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel() + } MuxUploadSdk.initialize(this) + if (MuxUploadManager.allUploadJobs().isNotEmpty()) { + UploadNotificationService.startCompat(this) + } + } + + @TargetApi(Build.VERSION_CODES.O) + private fun createNotificationChannel() { + val channel = NotificationChannel( + UploadNotificationService.CHANNEL_UPLOAD_PROGRESS, + getString(R.string.notif_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + channel.description = getString(R.string.notif_channel_desc) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } } diff --git a/app/src/main/java/com/mux/video/vod/demo/UploadNotificationService.kt b/app/src/main/java/com/mux/video/vod/demo/UploadNotificationService.kt new file mode 100644 index 00000000..5b7842f0 --- /dev/null +++ b/app/src/main/java/com/mux/video/vod/demo/UploadNotificationService.kt @@ -0,0 +1,221 @@ +package com.mux.video.vod.demo + +import android.annotation.SuppressLint +import android.annotation.TargetApi +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import com.mux.video.upload.api.MuxUpload +import com.mux.video.upload.api.MuxUploadManager +import com.mux.video.upload.api.UploadEventListener +import com.mux.video.upload.api.UploadStatus + +/** + * Service that monitors ongoing [MuxUpload]s, showing progress notifications for them. This + * service will enter the foreground whenever there are uploads in progress and will exit foreground + * and stop itself when there are no more uploads in progress (ie, all have completed, paused, or + * failed) + */ +class UploadNotificationService : Service() { + + companion object { + private const val TAG = "BackgroundUploadService" + + const val ACTION_START = "start" + const val NOTIFICATION_FG = 200002 + const val CHANNEL_UPLOAD_PROGRESS = "upload_progress" + + fun startCompat(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startImplApiO(context) + } else { + startImplLegacy(context) + } + } + + @TargetApi(Build.VERSION_CODES.O) + private fun startImplApiO(context: Context) { + val startIntent = Intent(context, UploadNotificationService::class.java) + startIntent.action = ACTION_START + context.startForegroundService(startIntent) + } + + private fun startImplLegacy(context: Context) { + val startIntent = Intent(context, UploadNotificationService::class.java) + startIntent.action = ACTION_START + context.startService(startIntent) + } + } + + private var uploadListListener: UploadListListener? = null + // uploads tracked by this Service, regardless of state. cleared when the service is destroyed + private val uploadsByFile = mutableMapOf() + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val action = intent?.action + if (action != ACTION_START) { + throw RuntimeException("Unknown action") + } + + // can be commanded to start arbitrary number of times + if (uploadListListener == null) { + notify(MuxUploadManager.allUploadJobs()) + + val lis = UploadListListener() + this.uploadListListener = lis + MuxUploadManager.addUploadsUpdatedListener(lis) + } + + return super.onStartCommand(intent, flags, startId) + } + + override fun onBind(intent: Intent?): IBinder? { + return MyBinder() + } + + override fun onDestroy() { + uploadListListener?.let { MuxUploadManager.removeUploadsUpdatedListener(it) } + } + + private fun notifyWithCurrentUploads() = notify(this.uploadsByFile.values) + + @SuppressLint("InlinedApi", "MissingPermission") // inline use of FOREGROUND_SERVICE + private fun notify(uploads: Collection) { + if (uploads.isEmpty()) { + // only notify if there are uploads being tracked (in-progress or finished) + return + } + + val uploadsInProgress = uploads.filter { it.isRunning } + val uploadsCompleted = uploads.filter { it.isSuccessful } + val uploadsFailed = uploads.filter { it.error != null } + + Log.v(TAG, "notify: uploadsInProgress: ${uploadsInProgress.size}") + Log.v(TAG, "notify: uploadsCompleted: ${uploadsCompleted.size}") + Log.v(TAG, "notify: uploadsFailed: ${uploadsFailed.size}") + + val builder = NotificationCompat.Builder(this, CHANNEL_UPLOAD_PROGRESS) + builder.setSmallIcon(R.drawable.ic_launcher) + builder.setAutoCancel(false) + builder.setOngoing(true) + + if (uploadsInProgress.isNotEmpty()) { + Log.d(TAG, "notifying progress") + if (uploadsInProgress.size == 1 && this.uploadsByFile.size == 1) { + // Special case: A single upload in progress, with a single upload requested + val upload = uploadsInProgress.first() + val kbUploaded = (upload.currentProgress.bytesUploaded / 1024).toInt() + val kbTotal = (upload.currentProgress.totalBytes / 1024).toInt() + + Log.d(TAG, "upload state: ${upload.uploadStatus}") + + builder.setProgress(kbTotal, kbUploaded, false) + builder.setContentText( + resources.getQuantityString( + R.plurals.notif_txt_uploading, 1, 1, 1 + ) + ) + builder.setContentTitle( + resources.getQuantityString( + R.plurals.notif_title_uploading, 1, 1 + ) + ) + } else { + // Multiple uploads requested simultaneously so we batch them into one + val totalKbUploaded = uploadsInProgress.sumOf { it.currentProgress.bytesUploaded / 1024 } + val totalKb = uploadsInProgress.sumOf { it.currentProgress.totalBytes / 1024 } + + builder.setProgress(totalKb.toInt(),totalKbUploaded.toInt(), false) + builder.setContentText( + resources.getQuantityString( + R.plurals.notif_txt_uploading, + uploads.size, + uploadsInProgress.size, + ) + ) + builder.setContentTitle( + resources.getQuantityString( + R.plurals.notif_title_uploading, uploads.size, + uploads.size + ) + ) + } + } else if (uploadsFailed.isNotEmpty()) { + Log.i(TAG, "notifying Fail") + builder.setContentTitle( + resources.getQuantityString( + R.plurals.notif_title_failed, + uploadsFailed.size, + uploadsFailed.size + ) + ) + builder.setContentText( + resources.getQuantityString( + R.plurals.notif_txt_failed, + uploadsFailed.size, + uploadsFailed.size + ) + ) + } else if (uploadsCompleted.isNotEmpty()) { + Log.i(TAG, "notifying Complete") + builder.setContentText(getString(R.string.notif_txt_success)) + builder.setContentTitle( + resources.getQuantityString( + R.plurals.notif_title_success, + uploadsCompleted.size, + uploadsCompleted.size, + ) + ) + } + + // always startForeground even if we're about to detach (to update the notification) + ServiceCompat.startForeground( + this, + NOTIFICATION_FG, + builder.build(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + + if (uploadsInProgress.isEmpty()) { + // we only need foreground/to even be running while uploads are actually running + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) + stopSelf() + } + } + + private fun updateCurrentUploads(incomingUploads: List) { + // listen to status of new uploads + incomingUploads + .filter { !this.uploadsByFile.containsKey(it.videoFile.path) } + .forEach { + this.uploadsByFile[it.videoFile.path] = it + it.setStatusListener(UploadStatusListener()) + } + } + + private inner class UploadListListener : UploadEventListener> { + override fun onEvent(event: List) { + val service = this@UploadNotificationService + service.updateCurrentUploads(event) + service.notifyWithCurrentUploads() + } + } + + private inner class UploadStatusListener : UploadEventListener { + override fun onEvent(event: UploadStatus) { + val service = this@UploadNotificationService + service.notifyWithCurrentUploads() + } + } + + private inner class MyBinder : Binder() { + fun getService(): UploadNotificationService = this@UploadNotificationService + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mux/video/vod/demo/backend/ImaginaryBackend.kt b/app/src/main/java/com/mux/video/vod/demo/backend/ImaginaryBackend.kt index c8570760..d02bfe34 100644 --- a/app/src/main/java/com/mux/video/vod/demo/backend/ImaginaryBackend.kt +++ b/app/src/main/java/com/mux/video/vod/demo/backend/ImaginaryBackend.kt @@ -56,8 +56,8 @@ object ImaginaryBackend { // note: You shouldn't do basic auth with hard-coded keys in a real app private fun basicCredential(): String = Credentials.basic(ACCESS_TOKEN_ID, ACCESS_TOKEN_SECRET) - private const val ACCESS_TOKEN_ID = "YOUR TOKEN ID HERE" - private const val ACCESS_TOKEN_SECRET = "YOUR TOKEN SECRET HERE" + private const val ACCESS_TOKEN_ID = "YOUR ACCESS TOKEN ID HERE" + private const val ACCESS_TOKEN_SECRET = "YOUR ACCESS TOKEN SECRET HERE" } private interface ImaginaryWebapp { diff --git a/app/src/main/java/com/mux/video/vod/demo/upload/screen/UploadListScreen.kt b/app/src/main/java/com/mux/video/vod/demo/upload/screen/UploadListScreen.kt index 39d5f7b5..429657a7 100644 --- a/app/src/main/java/com/mux/video/vod/demo/upload/screen/UploadListScreen.kt +++ b/app/src/main/java/com/mux/video/vod/demo/upload/screen/UploadListScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.mux.video.upload.api.MuxUpload +import com.mux.video.upload.api.UploadStatus import com.mux.video.vod.demo.R import com.mux.video.vod.demo.upload.CreateUploadActivity import com.mux.video.vod.demo.upload.CreateUploadCta @@ -175,10 +176,13 @@ private fun ListItemContent(upload: MuxUpload) { } } + val uploadState = remember { mutableStateOf(upload.uploadStatus) } + upload.setStatusListener { uploadState.value = it } + ListThumbnail(bitmap = bitmap) - if (upload.isSuccessful) { + if (uploadState.value.isSuccessful()) { DoneOverlay() - } else if (upload.error != null) { + } else if (uploadState.value.getError() != null) { ErrorOverlay(modifier = Modifier.fillMaxSize()) } else if (upload.isRunning) { ProgressOverlay( diff --git a/app/src/main/java/com/mux/video/vod/demo/upload/viewmodel/CreateUploadViewModel.kt b/app/src/main/java/com/mux/video/vod/demo/upload/viewmodel/CreateUploadViewModel.kt index a0588743..ae710302 100644 --- a/app/src/main/java/com/mux/video/vod/demo/upload/viewmodel/CreateUploadViewModel.kt +++ b/app/src/main/java/com/mux/video/vod/demo/upload/viewmodel/CreateUploadViewModel.kt @@ -3,7 +3,6 @@ package com.mux.video.vod.demo.upload.viewmodel import android.app.Application import android.database.Cursor import android.graphics.Bitmap -import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.provider.MediaStore @@ -13,6 +12,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.mux.video.upload.api.MuxUpload +import com.mux.video.vod.demo.UploadNotificationService import com.mux.video.vod.demo.backend.ImaginaryBackend import com.mux.video.vod.demo.upload.model.MediaStoreVideo import com.mux.video.vod.demo.upload.model.extractThumbnail @@ -67,6 +67,8 @@ class CreateUploadViewModel(private val app: Application) : AndroidViewModel(app ).build() // Force restart when creating brand new uploads (because we're making new Direct uploads) .start(forceRestart = true) + + UploadNotificationService.startCompat(app) } } @@ -74,7 +76,6 @@ class CreateUploadViewModel(private val app: Application) : AndroidViewModel(app * In order to upload a file from the device's media store, the file must be copied into the app's * temp directory. (Technically we could stream it from the source, but this prevents the other * app from modifying the file if we pause the upload for a long time or whatever) - * TODO Is this something that should go in the SDK? This is a common workflow */ @Throws private suspend fun copyIntoTempFile(contentUri: Uri): File { diff --git a/app/src/main/java/com/mux/video/vod/demo/upload/viewmodel/UploadListViewModel.kt b/app/src/main/java/com/mux/video/vod/demo/upload/viewmodel/UploadListViewModel.kt index 89d45b22..faed87a8 100644 --- a/app/src/main/java/com/mux/video/vod/demo/upload/viewmodel/UploadListViewModel.kt +++ b/app/src/main/java/com/mux/video/vod/demo/upload/viewmodel/UploadListViewModel.kt @@ -1,6 +1,7 @@ package com.mux.video.vod.demo.upload.viewmodel import android.app.Application +import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -21,8 +22,9 @@ class UploadListViewModel(app: Application) : AndroidViewModel(app) { private val uploadMap = mutableMapOf() private val listUpdateListener: UploadEventListener> by lazy { - UploadEventListener { newUploads -> - newUploads.forEach { uploadMap[it.videoFile] = it } + UploadEventListener { uploads -> + //uploadMap.forEach { entry -> entry.value.clearListeners() } + observeUploads(uploads) updateUiData(uploadMap.values.toList()) } } @@ -48,12 +50,7 @@ class UploadListViewModel(app: Application) : AndroidViewModel(app) { } private fun observeUploads(recentUploads: List) { - recentUploads.forEach { upload -> - upload.setProgressListener { - uploadMap[upload.videoFile] = upload - updateUiData(uploadMap.values.toList()) - } - } // recentUploads.forEach + recentUploads.forEach { upload -> uploadMap[upload.videoFile] = upload } } private fun updateUiData(list: List) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index edfc851e..395b05bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,9 +1,35 @@ - Mux Video VOD Upload SDK + Mux Upload Demo Mux Upload Plain-View Mux Upload Example Settings Pause Button Create New Upload Upload! - \ No newline at end of file + + Upload status notifications + Progress Notifications for uploads moving in the background + + Successfully uploaded %1$d videos + + Uploading video + Uploading %1$d videos + + + Upload Failed + %1$d uploads failed + + + Upload succeeded + Successfully uploaded %1$d videos + + + Uploading your video + Uploading videos.. (%1$d to go) + + + Upload Failed + %1$d uploads failed + + + diff --git a/build.gradle b/build.gradle index 64bfd76e..7a400288 100644 --- a/build.gradle +++ b/build.gradle @@ -4,8 +4,8 @@ buildscript { } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.1.2' apply false - id 'com.android.library' version '8.1.2' apply false - id 'org.jetbrains.kotlin.android' version '1.9.22' apply false + id 'com.android.application' version '8.4.2' apply false + id 'com.android.library' version '8.4.2' apply false + id 'org.jetbrains.kotlin.android' version '1.9.24' apply false id 'com.mux.gradle.android.mux-android-distribution' version '1.1.2' apply false } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d5e1fba2..3155ea18 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Jan 19 16:04:49 PST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME 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 371a8a82..d94c2786 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 @@ -1,12 +1,16 @@ package com.mux.video.upload.api import android.net.Uri +import android.util.Log import androidx.annotation.MainThread import com.mux.video.upload.MuxUploadSdk import com.mux.video.upload.api.MuxUpload.Builder +import com.mux.video.upload.internal.InputStandardization +import com.mux.video.upload.internal.MaximumResolution import com.mux.video.upload.internal.UploadInfo import com.mux.video.upload.internal.update import kotlinx.coroutines.* +import kotlinx.coroutines.flow.distinctUntilChangedBy import java.io.File /** @@ -33,7 +37,6 @@ import java.io.File class MuxUpload private constructor( private var uploadInfo: UploadInfo, private val autoManage: Boolean = true, - initialStatus: UploadStatus = UploadStatus.Ready ) { /** @@ -56,13 +59,18 @@ class MuxUpload private constructor( * To be notified of status updates (including upload progress), use [setStatusListener] */ @Suppress("MemberVisibilityCanBePrivate") - val uploadStatus: UploadStatus + val uploadStatus: UploadStatus get() = uploadInfo.statusFlow?.value ?: currentStatus /** * True when the upload is running, false if it's paused, failed, or canceled */ val isRunning get() = uploadInfo.isRunning() + /** + * True when the upload is paused by [pause], false otherwise + */ + val isPaused get() = currentStatus is UploadStatus.UploadPaused + /** * If the upload has failed, gets the error associated with the failure */ @@ -72,22 +80,25 @@ class MuxUpload private constructor( /** * True if the upload was successful, false otherwise */ - val isSuccessful get() = _successful + val isSuccessful get() = uploadInfo.statusFlow?.value?.isSuccessful() ?: _successful private var _successful: Boolean = false private var resultListener: UploadEventListener>? = null private var progressListener: UploadEventListener? = null private var statusListener: UploadEventListener? = null private var observerJob: Job? = null - private var currentStatus: UploadStatus = UploadStatus.Ready + private val currentStatus: UploadStatus get() = + uploadInfo.statusFlow?.value ?: lastKnownStatus ?: UploadStatus.Ready + private var lastKnownStatus: UploadStatus? = null private val lastKnownProgress: Progress? get() = currentStatus.getProgress() private val callbackScope: CoroutineScope = MainScope() private val logger get() = MuxUploadSdk.logger init { - // Catch Events if an upload was already in progress - observeUpload(uploadInfo) + // Catch state if an upload was already in progress + // no need to observe: the Flow will have the most-recent values when queried + uploadInfo.statusFlow?.value?.let { status -> this.lastKnownStatus = status } } /** @@ -194,8 +205,16 @@ class MuxUpload private constructor( */ @MainThread fun setProgressListener(listener: UploadEventListener?) { + if (listener == null) { + observerJob?.cancel("clearing listeners") + observerJob = null + } else { + observeUpload(uploadInfo) + } + progressListener = listener lastKnownProgress?.let { listener?.onEvent(it) } + observeUpload(uploadInfo) } /** @@ -204,11 +223,18 @@ class MuxUpload private constructor( * @see setStatusListener */ @MainThread - fun setResultListener(listener: UploadEventListener>) { + fun setResultListener(listener: UploadEventListener>?) { + if (listener == null) { + observerJob?.cancel("clearing listeners") + observerJob = null + } else { + observeUpload(uploadInfo) + } + resultListener = listener lastKnownProgress?.let { if (it.bytesUploaded >= it.totalBytes) { - listener.onEvent(Result.success(it)) + listener?.onEvent(Result.success(it)) } } } @@ -220,6 +246,13 @@ class MuxUpload private constructor( */ @MainThread fun setStatusListener(listener: UploadEventListener?) { + if (listener == null) { + observerJob?.cancel("clearing listeners") + observerJob = null + } else { + observeUpload(uploadInfo) + } + statusListener = listener listener?.onEvent(currentStatus) } @@ -230,38 +263,38 @@ class MuxUpload private constructor( @Suppress("unused") @MainThread fun clearListeners() { + observerJob?.cancel("clearing listeners") resultListener = null progressListener = null statusListener = null } - private fun newObserveProgressJob(upload: UploadInfo): Job { + private fun newObserveProgressJob(upload: UploadInfo): Job? { // Job that collects and notifies state updates on the main thread (suspending on main is safe) - return callbackScope.launch { - upload.statusFlow?.let { flow -> - launch { - flow.collect { status -> - // Update the status of our upload - currentStatus = status - statusListener?.onEvent(status) - - // Notify the old listeners - when (status) { - is UploadStatus.Uploading -> { progressListener?.onEvent(status.uploadProgress) } - is UploadStatus.UploadPaused -> { progressListener?.onEvent(status.uploadProgress) } - is UploadStatus.UploadSuccess -> { - progressListener?.onEvent(status.uploadProgress) - resultListener?.onEvent(Result.success(status.uploadProgress)) - } - is UploadStatus.UploadFailed -> { - progressListener?.onEvent(status.uploadProgress) // Make sure we're most up-to-date - if (status.exception !is CancellationException) { - _error = status.exception - resultListener?.onEvent(Result.failure(status.exception)) - } + return upload.statusFlow?.let { flow -> + callbackScope.launch { + flow.collect { status -> + // Update the status of our upload + lastKnownStatus = status + + statusListener?.onEvent(status) + // Notify the specific listeners + when (status) { + is UploadStatus.Uploading -> { progressListener?.onEvent(status.uploadProgress) } + is UploadStatus.UploadPaused -> { progressListener?.onEvent(status.uploadProgress) } + is UploadStatus.UploadSuccess -> { + _successful = true + progressListener?.onEvent(status.uploadProgress) + resultListener?.onEvent(Result.success(status.uploadProgress)) + } + is UploadStatus.UploadFailed -> { + progressListener?.onEvent(status.uploadProgress) // Make sure we're most up-to-date + if (status.exception !is CancellationException) { + _error = status.exception + resultListener?.onEvent(Result.failure(status.exception)) } - else -> { } // no relevant info } + else -> { } // no relevant info } } } @@ -273,10 +306,6 @@ class MuxUpload private constructor( observerJob = newObserveProgressJob(uploadInfo) } - init { - uploadStatus = initialStatus - } - /** * The current progress of an upload, in terms of time elapsed and data transmitted */ @@ -311,7 +340,7 @@ class MuxUpload private constructor( * @param videoFile a File that represents the video file you want to upload */ @Suppress("MemberVisibilityCanBePrivate") - class Builder constructor(val uploadUri: Uri, val videoFile: File) { + class Builder(val uploadUri: Uri, val videoFile: File) { /** * Create a new Builder with the specified input file and upload URL @@ -320,8 +349,7 @@ class MuxUpload private constructor( * @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) + constructor(uploadUri: String, videoFile: File): this(Uri.parse(uploadUri), videoFile) private var manageTask: Boolean = true private var uploadInfo: UploadInfo = UploadInfo( @@ -330,7 +358,6 @@ class MuxUpload private constructor( inputFile = videoFile, chunkSize = 8 * 1024 * 1024, // GCP recommends at least 8M chunk size retriesPerChunk = 3, - standardizationRequested = true, optOut = false, uploadJob = null, statusFlow = null, @@ -344,13 +371,23 @@ class MuxUpload private constructor( return this } + /** + * If requested, the Upload SDK will try to standardize the input file in order to optimize it + * for use with Mux Video + */ + @Suppress("unused") + fun standardizationRequested(enabled: Boolean, maxResolution: MaximumResolution): Builder { + uploadInfo.update(InputStandardization(enabled, maxResolution)) + return this + } + /** * If requested, the Upload SDK will try to standardize the input file in order to optimize it * for use with Mux Video */ @Suppress("unused") fun standardizationRequested(enabled: Boolean): Builder { - uploadInfo.update(standardizationRequested = enabled) + uploadInfo.update(InputStandardization(enabled)) return this } @@ -403,7 +440,6 @@ class MuxUpload private constructor( * [MuxUploadManager] */ @JvmSynthetic - internal fun create(uploadInfo: UploadInfo, initialStatus: UploadStatus = UploadStatus.Ready) - = MuxUpload(uploadInfo = uploadInfo, initialStatus = initialStatus) + internal fun create(uploadInfo: UploadInfo) = MuxUpload(uploadInfo = uploadInfo) } } diff --git a/library/src/main/java/com/mux/video/upload/api/UploadResult.kt b/library/src/main/java/com/mux/video/upload/api/UploadResult.kt index 31e96440..41ddb334 100644 --- a/library/src/main/java/com/mux/video/upload/api/UploadResult.kt +++ b/library/src/main/java/com/mux/video/upload/api/UploadResult.kt @@ -5,6 +5,7 @@ package com.mux.video.upload.api * * Kotlin callers can use the [Result] API as normal */ +@Suppress("unused") class UploadResult { companion object { diff --git a/library/src/main/java/com/mux/video/upload/api/UploadStatus.kt b/library/src/main/java/com/mux/video/upload/api/UploadStatus.kt index 556150ad..d4df0a55 100644 --- a/library/src/main/java/com/mux/video/upload/api/UploadStatus.kt +++ b/library/src/main/java/com/mux/video/upload/api/UploadStatus.kt @@ -35,19 +35,19 @@ sealed class UploadStatus { /** * This upload hos not been started. It is ready to start by calling [MuxUpload.start] */ - object Ready: UploadStatus() + data object Ready: UploadStatus() /** * This upload has been started via [MuxUpload.start] but has not yet started processing anything */ - object Started: UploadStatus() + data object Started: UploadStatus() /** * This upload is being prepared. If standardization is required, it is done during this step * * @see MuxUpload.Builder.standardizationRequested */ - object Preparing: UploadStatus() + data object Preparing: UploadStatus() /** * The upload is currently being sent to Mux Video. The progress is available diff --git a/library/src/main/java/com/mux/video/upload/internal/TranscoderContext.kt b/library/src/main/java/com/mux/video/upload/internal/TranscoderContext.kt index f5b5830d..38448c84 100644 --- a/library/src/main/java/com/mux/video/upload/internal/TranscoderContext.kt +++ b/library/src/main/java/com/mux/video/upload/internal/TranscoderContext.kt @@ -28,12 +28,14 @@ internal class TranscoderContext private constructor( ) { private val logger get() = MuxUploadSdk.logger - val MAX_ALLOWED_BITRATE = 8000000 - val MAX_ALLOWED_FRAMERATE = 120; - val MAX_ALLOWED_WIDTH = 1920 - val MAX_ALLOWED_HEIGTH = 1080 + val MAX_ALLOWED_BITRATE: Int + val MAX_ALLOWED_FRAMERATE = 120 + val MIN_ALLOWED_FRAMERATE = 5 + val MAX_ALLOWED_WIDTH: Int + val MAX_ALLOWED_HEIGTH: Int val OPTIMAL_FRAMERATE = 30 val I_FRAME_INTERVAL = 5 // in seconds + val MAX_ALLOWED_I_FRAME_INTERVAL: Int val OUTPUT_SAMPLERATE = 48000 val OUTPUT_NUMBER_OF_CHANNELS = 2 val OUTPUT_AUDIO_BITRATE = 96000 @@ -60,6 +62,7 @@ internal class TranscoderContext private constructor( private var targetedWidth = -1 private var targetedHeight = -1 private var targetedFramerate = -1 + private var targetedIFrameInterval = -1 private var targetedBitrate = -1 private var scaledSizeYuv: Nv12Buffer? = null private var resampleCreated = false @@ -100,6 +103,20 @@ internal class TranscoderContext private constructor( private var inputFileDurationMs:Long = 0 // Ms private var errorDescription = "" + init { + if (uploadInfo.inputStandardization.maximumResolution == MaximumResolution.Preset3840x2160) { + this.MAX_ALLOWED_BITRATE = 20000000 + this.MAX_ALLOWED_I_FRAME_INTERVAL = 10 + this.MAX_ALLOWED_WIDTH = 4096 + this.MAX_ALLOWED_HEIGTH = 4096 + } else { + this.MAX_ALLOWED_BITRATE = 8000000 + this.MAX_ALLOWED_I_FRAME_INTERVAL = 5 + this.MAX_ALLOWED_WIDTH = uploadInfo.inputStandardization.maximumResolution.width + this.MAX_ALLOWED_HEIGTH = uploadInfo.inputStandardization.maximumResolution.height + } + } + companion object { const val LOG_TAG = "TranscoderContext" @@ -212,7 +229,7 @@ internal class TranscoderContext private constructor( } inputFramerate = format.getIntegerCompat(MediaFormat.KEY_FRAME_RATE, -1) targetedFramerate = OPTIMAL_FRAMERATE - if (inputFramerate > MAX_ALLOWED_FRAMERATE) { + if (inputFramerate > MAX_ALLOWED_FRAMERATE || inputFramerate < MIN_ALLOWED_FRAMERATE) { logger.v( LOG_TAG, "Should standardize because the input frame rate is too high" @@ -223,6 +240,13 @@ internal class TranscoderContext private constructor( } else { targetedFramerate = inputFramerate } + val iFrameInterval = format.getInteger(MediaFormat.KEY_I_FRAME_INTERVAL) + if (iFrameInterval > MAX_ALLOWED_I_FRAME_INTERVAL) { + shouldStandardize = true + targetedIFrameInterval = I_FRAME_INTERVAL + } else { + targetedIFrameInterval = iFrameInterval + } videoTrackIndex = i; inputVideoFormat = format; extractor.selectTrack(i) @@ -316,7 +340,7 @@ internal class TranscoderContext private constructor( ) outputVideoFormat!!.setInteger("slice-height", targetedHeight + targetedHeight/2); outputVideoFormat!!.setInteger("stride", targetedWidth); - outputVideoFormat!!.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL) + outputVideoFormat!!.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, targetedIFrameInterval) outputVideoFormat!!.setInteger( MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR diff --git a/library/src/main/java/com/mux/video/upload/internal/UploadInfo.kt b/library/src/main/java/com/mux/video/upload/internal/UploadInfo.kt index a0d31406..0c86e925 100644 --- a/library/src/main/java/com/mux/video/upload/internal/UploadInfo.kt +++ b/library/src/main/java/com/mux/video/upload/internal/UploadInfo.kt @@ -7,6 +7,46 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.flow.StateFlow import java.io.File +@Suppress("unused") +enum class MaximumResolution(val width: Int, val height: Int) { + /** + * By default the standardized input will be + * scaled down to 1920x1080 (1080p) from a larger + * size. Inputs with smaller dimensions won't be + * scaled up. + */ + Default(1920, 1080), + + /** + * The standardized input will be scaled down + * to 1280x720 (720p) from a larger size. Inputs + * with smaller dimensions won't be scaled up. + */ + Preset1280x720(1280, 720), // 720p + + /** + * The standardized input will be scaled down + * to 1920x1080 (1080p) from a larger size. Inputs + * with smaller dimensions won't be scaled up. + */ + Preset1920x1080(1920, 1080), // 1080p + + /** + * The standardized input will be scaled down + * to 3840x2160 (2160p/4K) from a larger size. + * Inputs with smaller dimensions won't be scaled + * up. + */ + Preset3840x2160(3840, 2160) // 2160p +} + +data class InputStandardization( + @JvmSynthetic internal val standardizationRequested: Boolean = true, + @JvmSynthetic internal val maximumResolution: MaximumResolution = MaximumResolution.Default, +) { + +} + /** * This object is the SDK's internal representation of an upload that is in-progress. The public * object is [MuxUpload], which is backed by an instance of this object. @@ -18,7 +58,7 @@ import java.io.File * Job and Flows populated */ internal data class UploadInfo( - @JvmSynthetic internal val standardizationRequested: Boolean = true, + @JvmSynthetic internal val inputStandardization: InputStandardization = InputStandardization(), @JvmSynthetic internal val remoteUri: Uri, @JvmSynthetic internal val inputFile: File, @JvmSynthetic internal val standardizedFile: File? = null, @@ -28,7 +68,11 @@ internal data class UploadInfo( @JvmSynthetic internal val uploadJob: Deferred>?, @JvmSynthetic internal val statusFlow: StateFlow?, ) { - fun isRunning(): Boolean = uploadJob?.isActive ?: false + fun isRunning(): Boolean = + statusFlow?.value?.let { + it is UploadStatus.Uploading || it is UploadStatus.Started || it is UploadStatus.Preparing + } ?: false + fun isStandardizationRequested(): Boolean = inputStandardization.standardizationRequested } /** @@ -37,7 +81,7 @@ internal data class UploadInfo( */ @JvmSynthetic internal fun UploadInfo.update( - standardizationRequested: Boolean = this.standardizationRequested, + inputStandardization: InputStandardization = InputStandardization(), remoteUri: Uri = this.remoteUri, file: File = this.inputFile, standardizedFile: File? = this.standardizedFile, @@ -47,7 +91,7 @@ internal fun UploadInfo.update( uploadJob: Deferred>? = this.uploadJob, statusFlow: StateFlow? = this.statusFlow, ) = UploadInfo( - standardizationRequested, + inputStandardization, remoteUri, file, standardizedFile, diff --git a/library/src/main/java/com/mux/video/upload/internal/UploadJobFactory.kt b/library/src/main/java/com/mux/video/upload/internal/UploadJobFactory.kt index 9b04f590..96ad237f 100644 --- a/library/src/main/java/com/mux/video/upload/internal/UploadJobFactory.kt +++ b/library/src/main/java/com/mux/video/upload/internal/UploadJobFactory.kt @@ -78,7 +78,7 @@ internal class UploadJobFactory private constructor( val startTime = System.currentTimeMillis() try { // See if the file need to be converted to a standard input. - if (uploadInfo.standardizationRequested + if (uploadInfo.isStandardizationRequested() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ) { statusFlow.value = UploadStatus.Preparing 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 f5ff1f32..fcbdabd8 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 @@ -24,16 +24,6 @@ internal class UploadMetrics private constructor() { private val logger get() = MuxUploadSdk.logger - - private fun formatMilliseconds(ms:Long):String { - return String.format("%02d:%02d:%02d", - TimeUnit.MILLISECONDS.toHours(ms), - TimeUnit.MILLISECONDS.toMinutes(ms) - - TimeUnit.HOURS.toMinutes(TimeUnit.MILLISECONDS.toHours(ms)), // The change is in this line - TimeUnit.MILLISECONDS.toSeconds(ms) - - TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(ms))); - } - private suspend fun getEventInfo(startTimeMillis: Long, startTimeKey: String, endTimeMillis: Long, @@ -125,8 +115,8 @@ internal class UploadMetrics private constructor() { maximumResolution:String, sessionId: String, uploadInfo: UploadInfo - ) { - var body = JSONObject() + ) = runCatching { + val body = JSONObject() body.put("type", "upload_input_standardization_succeeded") body.put("session_id", sessionId) body.put("version", "1") @@ -149,8 +139,8 @@ internal class UploadMetrics private constructor() { maximumResolution:String, sessionId: String, uploadInfo: UploadInfo - ) { - var body = JSONObject() + ) = runCatching { + val body = JSONObject() body.put("type", "upload_input_standardization_failed") body.put("session_id", sessionId) body.put("version", "1") @@ -172,14 +162,14 @@ internal class UploadMetrics private constructor() { inputFileDurationMs: Long, sessionId: String, uploadInfo: UploadInfo - ) { - var body = JSONObject() + ) = runCatching { + val body = JSONObject() body.put("type", "upload_succeeded") body.put("session_id", sessionId) body.put("version", "1") val data = getEventInfo(startTimeMillis, "upload_start_time", endTimeMillis, "upload_end_time", inputFileDurationMs, uploadInfo) - data.put("input_standardization_requested", uploadInfo.standardizationRequested + data.put("input_standardization_requested", uploadInfo.isStandardizationRequested() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) body.put("data", data) sendPost(body) @@ -193,14 +183,14 @@ internal class UploadMetrics private constructor() { errorDescription:String, sessionId: String, uploadInfo: UploadInfo - ) { - var body = JSONObject() + ) = runCatching { + val body = JSONObject() body.put("type", "uploadfailed") body.put("session_id", sessionId) body.put("version", "1") val data = getEventInfo(startTimeMillis, "upload_start_time", endTimeMillis, "upload_end_time", inputFileDurationMs, uploadInfo) - data.put("input_standardization_requested", uploadInfo.standardizationRequested + data.put("input_standardization_requested", uploadInfo.isStandardizationRequested() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) data.put("error_description", errorDescription) body.put("data", data)