Skip to content
Merged
Show file tree
Hide file tree
Changes from 51 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
b5e4387
Inspect container and track metadata for Standard Input
eugene-mux Oct 2, 2026
047d2d9
Keep metadata fixture validation tooling external
eugene-mux Oct 2, 2026
b3a6aa1
Resolve metadata review findings and inspect effective timelines
eugene-mux Oct 2, 2026
5b174d8
Reuse Media3 timeline parsing and resolve metadata review findings
eugene-mux Oct 2, 2026
2b78e85
Keep ambiguous timeline and ISO rotation evidence unknown
eugene-mux Oct 2, 2026
75b52b9
Simplify metadata inspection and defer effective timeline validation
eugene-mux Oct 2, 2026
de0c618
Keep codec facts unknown when Media3 helpers are incompatible
eugene-mux Oct 2, 2026
67b9c88
Use platform metadata without duplicate codec or container parsing
eugene-mux Oct 16, 2026
e61e2fe
Retain missing pixel and color evidence without reconciling platform …
eugene-mux Oct 16, 2026
f541f79
Fill missing pre-API-24 profile evidence without overriding platform …
eugene-mux Oct 16, 2026
f02e5bf
Inspect bounded compressed video samples for Standard Input
eugene-mux Oct 6, 2026
0a10181
Fix sample inspection review findings
eugene-mux Oct 6, 2026
c9f486a
Limit ISO inspection to sample edit-list evidence
eugene-mux Oct 16, 2026
0bb70e4
Validate generated media before upload
eugene-mux Oct 6, 2026
5391f70
Fix output validation timing and bounded sample inspection
eugene-mux Oct 6, 2026
a24ebed
Unify ISO inspection and include trimmed video payload
eugene-mux Oct 6, 2026
e17ae3c
Simplify inspection results and regression setup
eugene-mux Oct 6, 2026
b1a5125
Correct edited bitrate spans and require source timeline proof
eugene-mux Oct 6, 2026
74d7250
Remove non-ISO sample scanning and raw duration guesses
eugene-mux Oct 6, 2026
c43fb88
Ignore dropped audio tracks in expected output duration
eugene-mux Oct 6, 2026
66eb9a5
Match timeline evidence using platform track identity
eugene-mux Oct 16, 2026
012b309
NAT-547: define SDR targets and bounded codec preflight
eugene-mux Oct 7, 2026
e58a99a
NAT-547: own cancellable Media3 SDR export and validated files
eugene-mux Oct 7, 2026
b2c1324
Integrate upload preparation and pause/cancel lifecycle
eugene-mux Oct 8, 2026
3de7171
NAT-547: cover terminal races and document adapter evidence
eugene-mux Oct 7, 2026
064c2e1
Preserve server audio selection for multi-track inputs
eugene-mux Oct 8, 2026
b0a3a25
NAT-547: harden SDR conversion and add reproducible review coverage
eugene-mux Oct 7, 2026
7ded0b2
Fix upload preparation lifecycle review findings
eugene-mux Oct 8, 2026
e077d82
NAT-547: remove internal README notes from product changes
eugene-mux Oct 7, 2026
853a71d
Fix listener reentrancy and upload replacement lifecycle
eugene-mux Oct 8, 2026
90ad3f5
Remove manual fixture tooling and test-only SDK entry points
eugene-mux Oct 7, 2026
37125bd
Follow adopted upload sessions and consolidate lifecycle tests
eugene-mux Oct 8, 2026
6e66ea2
Use actual VFR GOP windows and reject unproven rate hints
eugene-mux Oct 7, 2026
0ced030
NAT-548: Return cancellation failure to active await callers
eugene-mux Oct 8, 2026
0f87fed
Synchronize SDR test completion and share AAC layout mapping
eugene-mux Oct 7, 2026
803cdb9
NAT-548: Keep await callers attached across force restart
eugene-mux Oct 8, 2026
10889a3
Use normalized platform metadata in SDR conversion
eugene-mux Oct 16, 2026
0097f2a
Update upload preparation fixtures for platform metadata
eugene-mux Oct 16, 2026
1cc4386
Use coroutines and a cancellable continuation for SDR conversion
eugene-mux Oct 16, 2026
f50afbc
NAT-548: Integrate updated coroutine conversion adapter
eugene-mux Oct 16, 2026
454cc33
NAT-547: define SDR targets and bounded codec preflight
eugene-mux Oct 7, 2026
b9f3e3e
NAT-547: own cancellable Media3 SDR export and validated files
eugene-mux Oct 7, 2026
b710a35
NAT-547: cover terminal races and document adapter evidence
eugene-mux Oct 7, 2026
69327dc
NAT-547: harden SDR conversion and add reproducible review coverage
eugene-mux Oct 7, 2026
d5d1bb8
NAT-547: remove internal README notes from product changes
eugene-mux Oct 7, 2026
184834f
Remove manual fixture tooling and test-only SDK entry points
eugene-mux Oct 7, 2026
fcae48c
Use actual VFR GOP windows and reject unproven rate hints
eugene-mux Oct 7, 2026
707f47c
Synchronize SDR test completion and share AAC layout mapping
eugene-mux Oct 7, 2026
d39b5ed
Use normalized platform metadata in SDR conversion
eugene-mux Oct 16, 2026
fff4029
Use coroutines and a cancellable continuation for SDR conversion
eugene-mux Oct 16, 2026
0c6bd8b
NAT-548: Reconcile rebased SDR conversion base
eugene-mux Oct 16, 2026
df12667
NAT-548: Reconcile squash-merged feature base
eugene-mux Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ object MuxUploadSdk {
initializeUploadPersistence(realAppContext)
UploadMetrics.initialize(realAppContext)
if (resumeStoppedUploads) {
MuxUploadManager.resumeAllCachedJobs()
MuxUploadManager.resumeCachedJobs(includePaused = false)
}
}

Expand Down
244 changes: 162 additions & 82 deletions library/src/main/java/com/mux/video/upload/api/MuxUpload.kt

Large diffs are not rendered by default.

107 changes: 65 additions & 42 deletions library/src/main/java/com/mux/video/upload/api/MuxUploadManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ object MuxUploadManager {
uploadsByFilename[videoFile.absolutePath]?.let { MuxUpload.create(it) }

/**
* Finds all in-progress or paused uploads and returns [MuxUpload] objects representing them. You
* Finds all in-progress, paused, or failed 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 with the rest of your app
*/
Expand All @@ -58,17 +58,24 @@ object MuxUploadManager {
* The jobs will all be resumed where they left off. Any uploads resumed this way will be returned
*/
@MainThread
fun resumeAllCachedJobs(): List<MuxUpload> {
return readAllCachedUploads()
.filter { uploadInfo ->
val exists = uploadInfo.inputFile.exists()
if (!exists) {
forgetUploadState(uploadInfo)
}
exists
}
.onEach { uploadInfo -> startJob(uploadInfo, restart = false) }
.map { MuxUpload.create(it) }
fun resumeAllCachedJobs(): List<MuxUpload> = resumeCachedJobs(includePaused = true)

@JvmSynthetic
@MainThread
internal fun resumeCachedJobs(includePaused: Boolean): List<MuxUpload> {
val restored = readCachedUploadSnapshots().filter { cached ->
cached.upload.inputFile.exists().also { if (!it) forgetUploadState(cached.upload) }
}.map { (upload, saved) ->
if (!includePaused && saved.paused) {
uploadsByFilename.getOrPut(upload.inputFile.absolutePath) {
val attempt = createUploadAttempt(upload, saved)
attempt.pause {}
upload.update(attempt = attempt, statusFlow = attempt.status).also { it.session.current.value = it }
}
} else startJob(upload, restart = false, resumeState = saved)
}
notifyListListeners()
return restored.map { MuxUpload.create(it) }
}

/**
Expand Down Expand Up @@ -97,9 +104,9 @@ object MuxUploadManager {
*/
@JvmSynthetic
@MainThread
internal fun startJob(upload: UploadInfo, restart: Boolean = false): UploadInfo {
internal fun startJob(upload: UploadInfo, restart: Boolean = false, resumeState: UploadResumeState? = null): UploadInfo {
assertMainThread()
val updatedInfo = insertOrUpdateUpload(upload, restart)
val updatedInfo = insertOrUpdateUpload(upload, restart, resumeState)
notifyListListeners()
return updatedInfo
}
Expand All @@ -109,14 +116,11 @@ object MuxUploadManager {
internal fun pauseJob(upload: UploadInfo): UploadInfo {
assertMainThread()
// Paused jobs stay in the manager and remain persisted
uploadsByFilename[upload.inputFile.absolutePath]?.let {
uploadsByFilename[upload.inputFile.absolutePath]?.takeIf { it.session === upload.session }?.let {
it.attempt?.pause { state -> writeUploadState(it, state) }
cancelJobInner(it)
val pausedUpload = upload.update(
uploadJob = null,
statusFlow = null,
)
uploadsByFilename[pausedUpload.inputFile.absolutePath] = pausedUpload
return pausedUpload
notifyListListeners()
return it
}
notifyListListeners()
return upload
Expand All @@ -126,24 +130,31 @@ object MuxUploadManager {
@MainThread
internal fun cancelJob(upload: UploadInfo) {
assertMainThread()
uploadsByFilename[upload.inputFile.absolutePath]?.let {
observerJobsByFilename.remove(upload.inputFile.absolutePath)?.cancel()
cancelJobInner(it)
uploadsByFilename -= it.inputFile.absolutePath
forgetUploadState(upload)
val current = uploadsByFilename[upload.inputFile.absolutePath]
if (current != null && current.session !== upload.session) {
// An old destination's handle must not stop or forget its replacement.
upload.attempt?.cancel {}
upload.uploadJob?.cancel()
return
}
val cancelled = current ?: upload
observerJobsByFilename.remove(cancelled.inputFile.absolutePath)?.cancel()
cancelAttempt(cancelled)
uploadsByFilename -= cancelled.inputFile.absolutePath
notifyListListeners()
}

@JvmSynthetic
@MainThread
internal fun jobFinished(upload: UploadInfo, forgetJob: Boolean = true) {
assertMainThread()
// Persistence belongs to the attempt, even when it was never registered with the manager.
if (forgetJob) forgetUploadState(upload)
if (uploadsByFilename[upload.inputFile.absolutePath]?.attempt !== upload.attempt) return
observerJobsByFilename.remove(upload.inputFile.absolutePath)?.cancel()
uploadsByFilename -= upload.inputFile.absolutePath
if (forgetJob) {
forgetUploadState(upload)
}
// Keep failed jobs discoverable so existing handles follow a manager-driven retry.
if (forgetJob || upload.statusFlow?.value !is UploadStatus.UploadFailed)
uploadsByFilename -= upload.inputFile.absolutePath
notifyListListeners()
}

Expand All @@ -158,21 +169,33 @@ object MuxUploadManager {
upload.uploadJob?.cancel()
}

private fun insertOrUpdateUpload(upload: UploadInfo, restart: Boolean): UploadInfo {
private fun cancelAttempt(upload: UploadInfo, forgetUnstarted: Boolean = false, forRestart: Boolean = false) {
val attempt = upload.attempt
if (attempt != null) attempt.cancel(forRestart = forRestart) { forgetUploadState(upload) }
else if (forgetUnstarted) forgetUploadState(upload)
upload.uploadJob?.cancel()
}

private fun insertOrUpdateUpload(upload: UploadInfo, restart: Boolean, resumeState: UploadResumeState?): UploadInfo {
val filename = upload.inputFile.absolutePath
var newUpload = uploadsByFilename[filename]
// Use the old job if possible (unless requested otherwise)
if (newUpload?.uploadJob == null) {
if (restart) {
forgetUploadState(upload)
}
newUpload = startUploadJob(upload)
} else {
if (restart) {
cancelJob(upload)
newUpload = startUploadJob(upload)
val previous = uploadsByFilename[filename]
val newDestination = previous != null && previous.remoteUri != upload.remoteUri
if (!restart && !newDestination && previous?.uploadJob?.isActive == true && previous.attempt?.isStopped() != true)
return previous
var source = previous ?: upload
if (restart || newDestination) {
val generatedMayExistRemotely = !newDestination && (source.generatedResumeBlocked ||
source.attempt?.preparation?.generatedRequestStarted == true || readUploadResumeState(upload).generatedResumeBlocked)
if (newDestination) {
source.attempt?.replace { forgetUploadState(source) }
source.uploadJob?.cancel()
} else cancelAttempt(source, forgetUnstarted = true, forRestart = true)
source = upload.update(attempt = null, uploadJob = source.uploadJob,
statusFlow = null, restoredFromOriginal = false, generatedResumeBlocked = generatedMayExistRemotely).also {
it.session = if (newDestination) upload.session else source.session
}
}
val newUpload = startUploadJob(source, resumeState.takeIf { previous == null && !restart })
uploadsByFilename += upload.inputFile.absolutePath to newUpload
observerJobsByFilename[upload.inputFile.absolutePath]?.cancel()
observerJobsByFilename += upload.inputFile.absolutePath to newObserveProgressJob(newUpload)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package com.mux.video.upload.internal

import android.net.Uri
import android.util.Log
import com.mux.video.upload.MuxUploadSdk
import com.mux.video.upload.api.MuxUpload
import com.mux.video.upload.internal.network.asCountingRequestBody
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request
import okhttp3.Call
import okhttp3.Callback
import kotlin.coroutines.resumeWithException
import okhttp3.Response
import java.io.IOException

Expand Down Expand Up @@ -48,24 +49,16 @@ internal class ChunkWorker private constructor(
val moreRetries = { triesSoFar: Int -> triesSoFar < uploadInfo.retriesPerChunk }
suspend fun tryUpload(triesSoFar: Int): Result<MuxUpload.Progress> {
try {
currentCoroutineContext().ensureActive()
val (finalState, httpResponse) = doUpload()
if (ACCEPTABLE_STATUS_CODES.contains(httpResponse.code)) {
// End Case: Chunk success!
return Result.success(finalState)
} else if (RETRYABLE_STATUS_CODES.contains(httpResponse.code)) {
return if (moreRetries(triesSoFar)) {
// Still have more retries so try again
tryUpload(triesSoFar + 1)
} else {
Result.failure(
IOException("Upload request failed: ${httpResponse.code}/${httpResponse.message}")
)
}
} else {
return Result.failure(
IOException("Upload request failed: ${httpResponse.code}/${httpResponse.message}")
)
httpResponse.use {
if (ACCEPTABLE_STATUS_CODES.contains(it.code)) return Result.success(finalState)
val failure = IOException("Upload request failed: ${it.code}/${it.message}")
if (it.code !in RETRYABLE_STATUS_CODES || !moreRetries(triesSoFar)) return Result.failure(failure)
}
return tryUpload(triesSoFar + 1)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
return if (moreRetries(triesSoFar)) {
// Still have more retries so try again
Expand All @@ -76,7 +69,8 @@ internal class ChunkWorker private constructor(
}
}

return tryUpload(0).getOrThrow().also { writeUploadState(uploadInfo, it) }
currentCoroutineContext().ensureActive()
return tryUpload(0).getOrThrow()
}

@Throws
Expand Down Expand Up @@ -125,20 +119,32 @@ internal class ChunkWorker private constructor(
.build()

logger.v("MuxUpload", "Uploading with request $request")
val httpResponse = withContext(Dispatchers.IO) {
httpClient.newCall(request).execute()
val call = httpClient.newCall(request)
val httpResponse = suspendCancellableCoroutine<Response> { continuation ->
continuation.invokeOnCancellation { call.cancel() }
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { continuation.resumeWithException(e) }
override fun onResponse(call: Call, response: Response) {
continuation.resume(response) { _, value, _ -> value.close() }
}
})
}
try {
logger.v("MuxUpload", "Chunk Response: $httpResponse")
val finalState = MuxUpload.Progress(
bytesUploaded = chunkSize,
totalBytes = chunkSize,
startTime = startTime,
updatedTime = System.currentTimeMillis()
)
// Cancel progress updates and make sure no one is stuck listening for more
updateCallersJob?.cancel()
progressFlow.emit(finalState)
Pair(finalState, httpResponse)
} catch (e: Exception) {
httpResponse.close()
throw e
}
logger.v("MuxUpload", "Chunk Response: $httpResponse")
val finalState = MuxUpload.Progress(
bytesUploaded = chunkSize,
totalBytes = chunkSize,
startTime = startTime,
updatedTime = System.currentTimeMillis()
)
// Cancel progress updates and make sure no one is stuck listening for more
updateCallersJob?.cancel()
progressFlow.emit(finalState)
Pair(finalState, httpResponse)
} // supervisorScope
} // suspend fun doUpload

Expand Down
Loading