diff --git a/app/build.gradle b/app/build.gradle index f012f715..89bab66a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -38,7 +38,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.5.10' + kotlinCompilerExtensionVersion '1.5.14' } packagingOptions { resources { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f0e072e7..72052409 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/library/src/main/java/com/mux/video/upload/api/MuxUpload.kt b/library/src/main/java/com/mux/video/upload/api/MuxUpload.kt index c6c5bb0c..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,6 +1,7 @@ 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 @@ -9,6 +10,7 @@ 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 /** @@ -64,6 +66,11 @@ class MuxUpload private constructor( */ 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 */ @@ -73,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 } } /** @@ -195,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) } /** @@ -205,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)) } } } @@ -221,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) } @@ -231,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 } } } 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 620dfc9e..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 @@ -68,7 +68,10 @@ 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 }