Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -15,6 +15,7 @@
<action android:name="expo.modules.rustbridge.PAUSE_DOWNLOAD" />
<action android:name="expo.modules.rustbridge.RESUME_DOWNLOAD" />
<action android:name="expo.modules.rustbridge.CANCEL_DOWNLOAD" />
<action android:name="expo.modules.rustbridge.RETRY_DOWNLOAD" />
</intent-filter>
</receiver>
</application>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ fun copyStreamWithProgress(
input: InputStream,
output: OutputStream,
totalBytes: Long,
isCancelled: () -> Boolean = { false },
onProgress: (Int, Int) -> Unit
) {
val buffer = ByteArray(256 * 1024)
Expand All @@ -23,6 +24,8 @@ fun copyStreamWithProgress(
val startMs = System.currentTimeMillis()

while (true) {
// Cooperative cancellation: abort a long copy promptly when the user cancels.
if (isCancelled()) throw kotlinx.coroutines.CancellationException("Copy cancelled")
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package expo.modules.rustbridge

import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong

private const val TAG = "AudioValidation"

/** Result of the post-conversion corruption check. */
data class AudioValidationResult(
val isValid: Boolean,
val errorCount: Int,
val errorMessage: String,
val duration: Double,
val samplePoints: List<String> = emptyList()
)

private fun formatTimestamp(seconds: Long): String {
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val secs = seconds % 60
return "%02d:%02d:%02d".format(hours, minutes, secs)
}

/**
* Validate a decoded audiobook by decoding short samples at several points and counting
* FFmpeg errors. Shared by the foreground (DownloadOrchestrator) and background
* (DownloadWorker) pipelines; each supplies its own progress sink and cancel check so the
* behaviour is identical apart from where progress is reported.
*
* Depth is read from the "validation_level" preference: "full" (all points), "quick"
* (ends only) or "off" (skip). Progress + ETA are driven by a timer because the cost is
* seeking into a huge file, which emits no FFmpeg statistics.
*/
suspend fun validateAudioFile(
context: Context,
filePath: String,
isCancelled: () -> Boolean = { false },
onProgress: (pct: Int, etaSec: Int) -> Unit = { _, _ -> },
): AudioValidationResult = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Validating audio file: $filePath")

val probeSession = com.arthenica.ffmpegkit.FFprobeKit.getMediaInformation(filePath)
val duration = probeSession.mediaInformation?.duration?.toDoubleOrNull() ?: 0.0
if (duration <= 0) {
Log.e(TAG, "Invalid duration: $duration")
return@withContext AudioValidationResult(false, -1, "Could not determine file duration", 0.0)
}
Log.d(TAG, "File duration: ${duration}s (${duration / 3600}h)")

// Check: 30s, 25%, 50%, 75%, end-30s
val samplePoints = listOf(
30.0,
duration * 0.25,
duration * 0.50,
duration * 0.75,
maxOf(duration - 30, 60.0)
).distinct().sorted()

val validationLevel = context.getSharedPreferences("app_settings", Context.MODE_PRIVATE)
.getString("validation_level", "full") ?: "full"
if (validationLevel == "off") {
Log.d(TAG, "Validation skipped (setting=off)")
onProgress(100, 0)
return@withContext AudioValidationResult(true, 0, "Validation skipped by setting", duration)
}
val effectiveSamplePoints = if (validationLevel == "quick")
listOf(samplePoints.first(), samplePoints.last()).distinct()
else samplePoints

Log.d(TAG, "Sampling ${effectiveSamplePoints.size} points ($validationLevel): ${effectiveSamplePoints.map { "%.1fmin".format(it / 60) }}")

var totalErrors = 0
val sampleResults = mutableListOf<String>()
val totalSamples = effectiveSamplePoints.size
val testDuration = 10 // seconds decoded per sample

// Seed a timer-driven estimate BEFORE the first sample finishes, refined by each
// real sample's measured duration.
val completedSamples = AtomicInteger(0)
val sampleStartMs = AtomicLong(System.currentTimeMillis())
val avgSampleMs = AtomicLong(4000L)

val progressTicker = launch {
var lastPct = -1
while (isActive) {
val done = completedSamples.get()
val avg = avgSampleMs.get().toDouble()
val sampleElapsed = (System.currentTimeMillis() - sampleStartMs.get()).toDouble()
val subFrac = (sampleElapsed / avg).coerceIn(0.0, 0.99)
val overall = ((done + subFrac) / totalSamples).coerceIn(0.0, 0.999)
val pct = (overall * 100.0).toInt()
if (pct != lastPct) {
lastPct = pct
val remaining = (totalSamples - (done + subFrac)).coerceAtLeast(0.0)
val etaSec = (remaining * avg / 1000.0).toInt().coerceAtLeast(0)
onProgress(pct, etaSec)
}
delay(400)
}
}

try {
for ((index, timestamp) in effectiveSamplePoints.withIndex()) {
if (isCancelled()) throw kotlinx.coroutines.CancellationException("Validation cancelled by user")
sampleStartMs.set(System.currentTimeMillis())
val command = "-v error -ss $timestamp -i \"$filePath\" -t $testDuration -f null -"

val session = com.arthenica.ffmpegkit.FFmpegKit.execute(command)
val output = session.allLogsAsString

val errors = output.lines().count {
it.contains("Error", ignoreCase = true) ||
it.contains("Invalid data", ignoreCase = true)
}

totalErrors += errors
val statusMark = if (errors == 0) "✓" else "✗ $errors errors"
val timestampStr = formatTimestamp(timestamp.toLong())
sampleResults.add(" [$timestampStr] $statusMark")

val took = System.currentTimeMillis() - sampleStartMs.get()
avgSampleMs.set(
if (index == 0) took.coerceAtLeast(250L)
else (0.6 * avgSampleMs.get() + 0.4 * took).toLong().coerceAtLeast(250L)
)
completedSamples.set(index + 1)

Log.d(TAG, "Sample ${index + 1}/$totalSamples at $timestampStr: $errors errors (${took}ms)")

if (errors > 50) {
Log.w(TAG, "High error count detected at $timestampStr, stopping validation")
break
}
}
} finally {
progressTicker.cancel()
onProgress(100, 0)
}

val isValid = totalErrors == 0
val errorMessage = if (isValid) {
"Audio file validated successfully"
} else {
"Audio corruption detected: $totalErrors total errors\n${sampleResults.joinToString("\n")}"
}
Log.d(TAG, "Validation result: ${if (isValid) "VALID" else "CORRUPT"} ($totalErrors errors)")

AudioValidationResult(isValid, totalErrors, errorMessage, duration, sampleResults)
} catch (e: kotlinx.coroutines.CancellationException) {
// A user cancel must propagate as a cancel, not masquerade as a corrupt file —
// callers delete the cached source on a failed validation.
throw e
} catch (e: Exception) {
Log.e(TAG, "Error validating audio file", e)
AudioValidationResult(false, -1, "Validation failed: ${e.message}", 0.0)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class DownloadActionReceiver : BroadcastReceiver() {
const val ACTION_PAUSE = "expo.modules.rustbridge.PAUSE_DOWNLOAD"
const val ACTION_RESUME = "expo.modules.rustbridge.RESUME_DOWNLOAD"
const val ACTION_CANCEL = "expo.modules.rustbridge.CANCEL_DOWNLOAD"
const val ACTION_RETRY = "expo.modules.rustbridge.RETRY_DOWNLOAD"
}

override fun onReceive(context: Context, intent: Intent) {
Expand Down Expand Up @@ -48,6 +49,10 @@ class DownloadActionReceiver : BroadcastReceiver() {
Log.d(TAG, "Handling cancel action")
handleCancel(context, dbPath, asin)
}
ACTION_RETRY -> {
Log.d(TAG, "Handling retry action")
handleRetry(context, dbPath, asin)
}
else -> {
Log.w(TAG, "Unknown action: ${intent.action}")
}
Expand Down Expand Up @@ -190,6 +195,17 @@ class DownloadActionReceiver : BroadcastReceiver() {
}
}

private fun handleRetry(context: Context, dbPath: String, asin: String) {
try {
Log.d(TAG, "Retrying conversion for $asin")
DownloadService.retryConversion(context, dbPath, asin)
// Dismiss the failure notification; a progress notification replaces it.
DownloadNotificationManager(context).cancelForAsin(asin)
} catch (e: Exception) {
Log.e(TAG, "Error handling retry", e)
}
}

private fun handleCancel(context: Context, dbPath: String, asin: String) {
try {
// Find the task ID for this ASIN
Expand All @@ -208,40 +224,36 @@ class DownloadActionReceiver : BroadcastReceiver() {
val task = tasks.find { it["asin"] == asin }
val taskId = task?.get("task_id") as? String

// Best-effort: cancel the Rust download if it is still running. Past the
// download stage (decrypt/validate/copy) the download has already finished,
// so this can report failure — that is fine, the cleanup below still runs.
if (taskId != null) {
// Cancel the download
val cancelParams = JSONObject().apply {
put("db_path", dbPath)
put("task_id", taskId)
}

val cancelResult = ExpoRustBridgeModule.nativeCancelDownload(cancelParams.toString())
val cancelParsed = parseJsonResponse(cancelResult)

if (cancelParsed["success"] == true) {
// Clear manual pause marker
clearManuallyPaused(context, asin)

Log.d(TAG, "Successfully cancelled download: $asin")

// Stop orchestrator monitoring (stops any ongoing conversion)
val stopIntent = Intent(context, DownloadService::class.java).apply {
action = "expo.modules.rustbridge.STOP_MONITORING"
putExtra("asin", asin)
}
context.startService(stopIntent)
Log.d(TAG, "Sent stop monitoring intent for $asin")

// Cancel all notifications
val notificationManager = DownloadNotificationManager(context)
asin?.let { notificationManager.cancelForAsin(it) }
Log.d(TAG, "Cleared all notifications for cancelled download")
} else {
Log.e(TAG, "Failed to cancel: ${cancelParsed["error"]}")
val cancelParsed = parseJsonResponse(
ExpoRustBridgeModule.nativeCancelDownload(cancelParams.toString())
)
if (cancelParsed["success"] != true) {
Log.w(TAG, "nativeCancelDownload did not cancel $asin (likely past the download stage): ${cancelParsed["error"]}")
}
} else {
Log.e(TAG, "Task not found for ASIN: $asin")
Log.w(TAG, "No download task for $asin; treating as a conversion/queue cancel")
}

// Always stop monitoring — this aborts an in-flight conversion (decrypt /
// validate / copy) and dequeues a queued book. Previously this was gated on
// the Rust cancel succeeding, so a mid-copy cancel never reached the abort
// and the copy ran to completion.
clearManuallyPaused(context, asin)
val stopIntent = Intent(context, DownloadService::class.java).apply {
action = "expo.modules.rustbridge.STOP_MONITORING"
putExtra("asin", asin)
}
context.startService(stopIntent)
DownloadNotificationManager(context).cancelForAsin(asin)
Log.d(TAG, "Cancelled/cleaned up download: $asin")
}
} catch (e: Exception) {
Log.e(TAG, "Error handling cancel", e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class DownloadNotificationManager(private val context: Context) {
private const val ACTION_PAUSE = "expo.modules.rustbridge.PAUSE_DOWNLOAD"
private const val ACTION_RESUME = "expo.modules.rustbridge.RESUME_DOWNLOAD"
private const val ACTION_CANCEL = "expo.modules.rustbridge.CANCEL_DOWNLOAD"
private const val ACTION_RETRY = "expo.modules.rustbridge.RETRY_DOWNLOAD"

// Notification types
const val STAGE_DOWNLOADING = "downloading"
Expand Down Expand Up @@ -148,17 +149,17 @@ class DownloadNotificationManager(private val context: Context) {
.setStyle(NotificationCompat.BigTextStyle().bigText(bigText))
.setOnlyAlertOnce(true) // Don't make sound/vibration on updates

// Add action buttons only during download stage
// Per-ASIN request codes: a constant code + FLAG_UPDATE_CURRENT makes every
// notification's button share one PendingIntent, so its extras (asin) get
// overwritten by the last one built — cancelling then hits the wrong book.
val reqBase = notificationIdFor(progress.asin) * 2

// Pause only makes sense while downloading (the Rust download supports resume).
if (progress.stage == STAGE_DOWNLOADING) {
// Pause button
val pauseIntent = Intent(ACTION_PAUSE).apply {
setPackage(context.packageName)
putExtra("asin", progress.asin)
}
// Per-ASIN request codes: a constant code + FLAG_UPDATE_CURRENT makes every
// notification's button share one PendingIntent, so its extras (asin) get
// overwritten by the last one built — cancelling then hits the wrong book.
val reqBase = notificationIdFor(progress.asin) * 2
val pausePendingIntent = PendingIntent.getBroadcast(
context,
reqBase,
Expand All @@ -170,24 +171,24 @@ class DownloadNotificationManager(private val context: Context) {
"Pause",
pausePendingIntent
)
}

// Cancel button
val cancelIntent = Intent(ACTION_CANCEL).apply {
setPackage(context.packageName)
putExtra("asin", progress.asin)
}
val cancelPendingIntent = PendingIntent.getBroadcast(
context,
reqBase + 1,
cancelIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
builder.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"Cancel",
cancelPendingIntent
)
// Cancel is available in every stage, including decrypt / validate / copy.
val cancelIntent = Intent(ACTION_CANCEL).apply {
setPackage(context.packageName)
putExtra("asin", progress.asin)
}
val cancelPendingIntent = PendingIntent.getBroadcast(
context,
reqBase + 1,
cancelIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
builder.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"Cancel",
cancelPendingIntent
)

return builder.build()
}
Expand Down Expand Up @@ -234,16 +235,28 @@ class DownloadNotificationManager(private val context: Context) {
append("$title")
author?.let { append("\nby $it") }
append("\n\nFailed: $error")
append("\n\nTap to retry")
}

// Retry action: re-runs the conversion from the cached encrypted file.
val retryIntent = Intent(ACTION_RETRY).apply {
setPackage(context.packageName)
putExtra("asin", asin)
}
val retryPendingIntent = PendingIntent.getBroadcast(
context,
notificationIdFor(asin) * 2 + 1,
retryIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle("Download Failed: $title")
.setContentText(contentText)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setStyle(NotificationCompat.BigTextStyle().bigText(bigText))
.addAction(android.R.drawable.ic_menu_rotate, "Retry", retryPendingIntent)
.build()

// Replace this book's ongoing progress notification in place.
Expand Down
Loading