From ca747f8ce27e56c9a3682567e7b2e08cc9c429a9 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:00:44 +0700 Subject: [PATCH] fix(android): release temporary media on failed SAF operations --- .../zarz/spotiflac/DownloadServiceNetwork.kt | 20 ---- .../spotiflac/DownloadServiceReplayGain.kt | 25 ---- .../zarz/spotiflac/DownloadServiceSnapshot.kt | 21 ---- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 2 - .../com/zarz/spotiflac/MainActivitySafIo.kt | 96 +++------------ .../com/zarz/spotiflac/MainActivitySafScan.kt | 25 ---- .../zarz/spotiflac/NativeDownloadFinalizer.kt | 11 -- .../zarz/spotiflac/NativeFinalizerFFmpeg.kt | 17 --- .../zarz/spotiflac/NativeFinalizerMedia.kt | 20 ---- .../spotiflac/NativeFinalizerSafPublish.kt | 26 ---- .../zarz/spotiflac/NativeMediaWorkspace.kt | 29 +++++ .../spotiflac/NativeTemporaryMediaFileTest.kt | 113 ++++++++++++++++++ 12 files changed, 161 insertions(+), 244 deletions(-) create mode 100644 android/app/src/test/kotlin/com/zarz/spotiflac/NativeTemporaryMediaFileTest.kt diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceNetwork.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceNetwork.kt index ff22f6f3..a63c12e7 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceNetwork.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceNetwork.kt @@ -1,34 +1,14 @@ package com.zarz.spotiflac -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest -import android.os.Build -import android.os.IBinder -import android.os.PowerManager -import android.util.AtomicFile -import androidx.core.app.NotificationCompat -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.json.JSONArray import org.json.JSONObject -import java.io.File -import java.util.concurrent.atomic.AtomicLong // Wi-Fi-only download policy: network callback, pause and resume. diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceReplayGain.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceReplayGain.kt index bcab79d2..b49090e6 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceReplayGain.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceReplayGain.kt @@ -1,34 +1,9 @@ package com.zarz.spotiflac -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest -import android.os.Build -import android.os.IBinder -import android.os.PowerManager import android.util.AtomicFile -import androidx.core.app.NotificationCompat -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject import java.io.File -import java.util.concurrent.atomic.AtomicLong // Album ReplayGain accumulation and journal persistence for the native worker. diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceSnapshot.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceSnapshot.kt index 751bdad2..45437664 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceSnapshot.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadServiceSnapshot.kt @@ -1,27 +1,7 @@ package com.zarz.spotiflac -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest -import android.os.Build -import android.os.IBinder -import android.os.PowerManager import android.util.AtomicFile -import androidx.core.app.NotificationCompat -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -29,7 +9,6 @@ import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject import java.io.File -import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference // Native-worker item state snapshots for the Flutter side. diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index c9b5ed12..aba4b1bf 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -37,8 +37,6 @@ import org.json.JSONArray import org.json.JSONObject import org.json.JSONTokener import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream import java.security.MessageDigest import java.security.SecureRandom import java.util.LinkedHashMap diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt index 2a7141ca..fb3d5d88 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt @@ -1,43 +1,14 @@ package com.zarz.spotiflac -import android.app.Activity -import android.content.Context -import android.content.Intent import android.net.Uri -import android.os.Build -import android.os.Bundle import android.os.ParcelFileDescriptor import android.system.Os import android.system.OsConstants -import androidx.activity.OnBackPressedCallback -import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile -import io.flutter.embedding.android.FlutterFragmentActivity -import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode -import io.flutter.embedding.android.FlutterFragment -import io.flutter.embedding.android.RenderMode -import io.flutter.embedding.android.TransparencyMode -import io.flutter.embedding.engine.FlutterEngine -import io.flutter.embedding.engine.FlutterShellArgs -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MethodChannel -import com.ryanheise.audioservice.AudioServicePlugin -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.json.JSONArray import org.json.JSONObject -import org.json.JSONTokener import java.io.File import java.io.FileInputStream -import java.io.FileOutputStream import java.io.IOException -import java.security.MessageDigest import java.util.Locale // SAF/MediaStore URI IO helpers: temp copies, writes, sidecars, and the @@ -129,9 +100,6 @@ internal fun MainActivity.copyUriToTemp( fallbackExt: String? = null, displayName: String? = null, ): String? { - var tempFile: File? = null - var success = false - try { val nameHint = ( displayName @@ -146,16 +114,10 @@ internal fun MainActivity.copyUriToTemp( ) val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "") val suffix = ext.ifBlank { ".tmp" } - tempFile = coreBackend.createTemporaryMediaFile(this, "saf_", suffix) - - contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(tempFile).use { output -> - input.copyTo(output, bufferSize = 256 * 1024) - } - } ?: return null - - success = true - return tempFile.absolutePath + val tempFile = coreBackend.createTemporaryMediaFile(this, "saf_", suffix) + return copyToTemporaryMediaFile(tempFile) { + contentResolver.openInputStream(uri) + }?.absolutePath } catch (e: SecurityException) { // SAF permission denied - try MediaStore fallback for Samsung One UI // which may return MediaStore URIs from SAF tree traversal @@ -180,12 +142,6 @@ internal fun MainActivity.copyUriToTemp( "Failed copying SAF uri $uri to temp: ${e.message}", ) return null - } finally { - if (!success) { - try { - tempFile?.delete() - } catch (_: Exception) {} - } } } @@ -196,32 +152,24 @@ internal fun MainActivity.copyUriToTemp( * that the SAF document provider cannot access. */ internal fun MainActivity.copyMediaStoreUriToTemp(uri: Uri, fallbackExt: String?): String? { - var tempFile: File? = null try { val ext = resolveMediaStoreExt(uri, fallbackExt) val suffix = ext.ifBlank { ".tmp" } - tempFile = coreBackend.createTemporaryMediaFile(this, "ms_", suffix) - - contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(tempFile).use { output -> - input.copyTo(output, bufferSize = 256 * 1024) - } - } ?: run { - tempFile.delete() - return null - } + val tempFile = coreBackend.createTemporaryMediaFile(this, "ms_", suffix) + val copied = copyToTemporaryMediaFile(tempFile) { + contentResolver.openInputStream(uri) + } ?: return null android.util.Log.d( "SpotiFLAC", "MediaStore fallback succeeded for $uri", ) - return tempFile.absolutePath + return copied.absolutePath } catch (e: Exception) { android.util.Log.w( "SpotiFLAC", "MediaStore fallback also failed for $uri: ${e.message}", ) - try { tempFile?.delete() } catch (_: Exception) {} return null } } @@ -475,11 +423,11 @@ internal fun MainActivity.writeSafSidecarLrc(audioUri: Uri, lrcContent: String): } internal fun MainActivity.runPostProcessingSafV2(fileUriStr: String, metadataJson: String, itemId: String): String { - val uri = Uri.parse(fileUriStr) - val doc = DocumentFile.fromSingleUri(this, uri) - ?: return errorJson("SAF file not found") - - val tempInput = copyUriToTemp(uri) ?: return errorJson("Failed to copy SAF file to temp") + val uri = Uri.parse(fileUriStr) + val doc = DocumentFile.fromSingleUri(this, uri) + ?: return errorJson("SAF file not found") + val tempInput = copyUriToTemp(uri) ?: return errorJson("Failed to copy SAF file to temp") + return withOwnedTemporaryMediaFile(File(tempInput)) { val inputObj = JSONObject() inputObj.put("item_id", itemId) inputObj.put("path", tempInput) @@ -492,22 +440,16 @@ internal fun MainActivity.runPostProcessingSafV2(fileUriStr: String, metadataJso val response = coreBackend.runPostProcessing(inputObj.toString(), metadataJson) val respObj = JSONObject(response) if (!respObj.optBoolean("success", false)) { - try { - File(tempInput).delete() - } catch (_: Exception) {} - return response + return@withOwnedTemporaryMediaFile response } val newPath = respObj.optString("new_file_path", "") val outputPath = if (newPath.isNotBlank()) newPath else tempInput val outputFile = File(outputPath) if (!outputFile.exists()) { - try { - File(tempInput).delete() - } catch (_: Exception) {} respObj.put("success", false) respObj.put("error", "postProcess output not found") - return respObj.toString() + return@withOwnedTemporaryMediaFile respObj.toString() } val newName = outputFile.name @@ -521,17 +463,17 @@ internal fun MainActivity.runPostProcessingSafV2(fileUriStr: String, metadataJso if (!writeOk) { respObj.put("success", false) respObj.put("error", "failed to write postProcess output to SAF") - return respObj.toString() + return@withOwnedTemporaryMediaFile respObj.toString() } try { if (outputPath != tempInput) { outputFile.delete() } - File(tempInput).delete() } catch (_: Exception) {} respObj.put("new_file_path", uri.toString()) respObj.put("file_path", uri.toString()) - return respObj.toString() + respObj.toString() } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt index 53780666..2859a6c3 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -1,40 +1,15 @@ package com.zarz.spotiflac -import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri -import android.os.Build -import android.os.Bundle import android.provider.DocumentsContract import android.provider.DocumentsContract.Document.MIME_TYPE_DIR -import androidx.activity.OnBackPressedCallback -import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile -import io.flutter.embedding.android.FlutterFragmentActivity -import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode -import io.flutter.embedding.android.FlutterFragment -import io.flutter.embedding.android.RenderMode -import io.flutter.embedding.android.TransparencyMode -import io.flutter.embedding.engine.FlutterEngine -import io.flutter.embedding.engine.FlutterShellArgs -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MethodChannel -import com.ryanheise.audioservice.AudioServicePlugin -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject -import org.json.JSONTokener import java.io.File -import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.RandomAccessFile diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index 3ad882a1..76f8dd1e 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -6,27 +6,16 @@ import android.net.Uri import android.util.Base64 import android.util.Log import com.antonkarpenko.ffmpegkit.FFmpegKit -import com.antonkarpenko.ffmpegkit.FFmpegKitConfig -import com.antonkarpenko.ffmpegkit.FFmpegSession import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback -import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy -import com.antonkarpenko.ffmpegkit.ReturnCode import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt import com.zarz.spotiflac.SafDownloadHandler.normalizeExt -import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality -import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension import org.json.JSONObject import java.io.File -import java.io.RandomAccessFile -import java.nio.ByteBuffer import java.util.Locale import java.util.concurrent.CancellationException -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.abs import kotlin.math.pow diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt index 9c30757f..53287ed2 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerFFmpeg.kt @@ -1,31 +1,14 @@ package com.zarz.spotiflac -import android.content.ContentValues -import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteException -import android.net.Uri -import android.util.Base64 import android.util.Log import com.antonkarpenko.ffmpegkit.FFmpegKit import com.antonkarpenko.ffmpegkit.FFmpegKitConfig import com.antonkarpenko.ffmpegkit.FFmpegSession -import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy import com.antonkarpenko.ffmpegkit.ReturnCode -import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt import com.zarz.spotiflac.SafDownloadHandler.normalizeExt -import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel -import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality -import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag -import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec -import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec -import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec -import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension -import org.json.JSONObject import java.io.File import java.io.RandomAccessFile -import java.nio.ByteBuffer import java.util.Locale import java.util.concurrent.CancellationException import java.util.concurrent.CountDownLatch diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt index 578491ba..cd557439 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt @@ -1,42 +1,22 @@ package com.zarz.spotiflac -import android.content.ContentValues import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteException -import android.net.Uri import android.util.Base64 import android.util.Log -import com.antonkarpenko.ffmpegkit.FFmpegKit -import com.antonkarpenko.ffmpegkit.FFmpegKitConfig -import com.antonkarpenko.ffmpegkit.FFmpegSession -import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback -import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy -import com.antonkarpenko.ffmpegkit.ReturnCode import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt import com.zarz.spotiflac.SafDownloadHandler.normalizeExt import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel import com.zarz.spotiflac.NativeFinalizationPolicy.artistTagValue import com.zarz.spotiflac.NativeFinalizationPolicy.authoritativeAlbumArtist -import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag -import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec -import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.logicalOutputFileName import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel import com.zarz.spotiflac.NativeFinalizationPolicy.resolveQualityVariantFilename -import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec -import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension import org.json.JSONObject import java.io.File import java.io.RandomAccessFile import java.nio.ByteBuffer import java.util.Locale -import java.util.concurrent.CancellationException -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.math.pow // Metadata embedding, cover download, external LRC, and quality-variant diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt index 8721528b..43313bb6 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt @@ -1,38 +1,12 @@ package com.zarz.spotiflac -import android.content.ContentValues import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteException -import android.net.Uri -import android.util.Base64 import android.util.Log -import com.antonkarpenko.ffmpegkit.FFmpegKit -import com.antonkarpenko.ffmpegkit.FFmpegKitConfig -import com.antonkarpenko.ffmpegkit.FFmpegSession -import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback -import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy -import com.antonkarpenko.ffmpegkit.ReturnCode import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt -import com.zarz.spotiflac.SafDownloadHandler.normalizeExt import com.zarz.spotiflac.NativeFinalizationPolicy.applyQualityVariantFilenameLabel -import com.zarz.spotiflac.NativeFinalizationPolicy.displayAudioQuality -import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag -import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec -import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec -import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel -import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension import org.json.JSONObject import java.io.File -import java.io.RandomAccessFile -import java.nio.ByteBuffer -import java.util.Locale -import java.util.concurrent.CancellationException -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.math.pow // Deferred SAF publish helpers for NativeDownloadFinalizer. diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeMediaWorkspace.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeMediaWorkspace.kt index 35f2f050..f7924684 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeMediaWorkspace.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeMediaWorkspace.kt @@ -1,7 +1,9 @@ package com.zarz.spotiflac import java.io.File +import java.io.FileOutputStream import java.io.IOException +import java.io.InputStream /** One workspace per process; in-flight media must survive cache eviction. */ internal class NativeMediaWorkspace(privateDirectory: File) { @@ -23,3 +25,30 @@ internal class NativeMediaWorkspace(privateDirectory: File) { fun createTemporaryFile(prefix: String, suffix: String): File = File.createTempFile(prefix, suffix, ensureDirectory()) } + +/** Only the caller's newly created input belongs to this operation, not a hook's replacement. */ +internal inline fun withOwnedTemporaryMediaFile(file: File, block: (File) -> T): T { + try { + return block(file) + } finally { + try { file.delete() } catch (_: Exception) {} + } +} + +/** Transfers ownership only after a complete copy; failed reads retire the partial input. */ +internal fun copyToTemporaryMediaFile(file: File, openInput: () -> InputStream?): File? { + var complete = false + try { + openInput()?.use { input -> + FileOutputStream(file).use { output -> + input.copyTo(output, bufferSize = 256 * 1024) + } + } ?: return null + complete = true + return file + } finally { + if (!complete) { + try { file.delete() } catch (_: Exception) {} + } + } +} diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeTemporaryMediaFileTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeTemporaryMediaFileTest.kt new file mode 100644 index 00000000..c43f5420 --- /dev/null +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeTemporaryMediaFileTest.kt @@ -0,0 +1,113 @@ +package com.zarz.spotiflac + +import java.io.ByteArrayInputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeTemporaryMediaFileTest { + @Test + fun completedCopyClosesTheInputAndTransfersTheTemporaryFileToItsCaller() { + val directory = Files.createTempDirectory("native-media-copy-test").toFile() + try { + val temporary = File.createTempFile("saf_", ".flac", directory) + var closed = false + val input = object : ByteArrayInputStream("audio".toByteArray()) { + override fun close() { + closed = true + super.close() + } + } + + assertSame(temporary, copyToTemporaryMediaFile(temporary) { input }) + assertTrue(closed) + assertEquals("audio", temporary.readText()) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun absentOrDeniedInputRemovesTheUnusedTemporaryFile() { + val directory = Files.createTempDirectory("native-media-copy-test").toFile() + try { + val absent = File.createTempFile("saf_", ".flac", directory) + assertEquals(null, copyToTemporaryMediaFile(absent) { null }) + assertFalse(absent.exists()) + + val denied = File.createTempFile("saf_", ".flac", directory) + val failure = SecurityException("permission revoked") + assertSame(failure, assertThrows(SecurityException::class.java) { + copyToTemporaryMediaFile(denied) { throw failure } + }) + assertFalse(denied.exists()) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun interruptedCopyClosesTheInputRemovesPartialBytesAndPreservesTheError() { + val directory = Files.createTempDirectory("native-media-copy-test").toFile() + try { + val temporary = File.createTempFile("saf_", ".flac", directory) + val failure = IOException("remote read interrupted") + var closed = false + val input = object : InputStream() { + var started = false + override fun read(): Int = throw failure + override fun read(bytes: ByteArray, offset: Int, length: Int): Int { + if (started) throw failure + started = true + bytes[offset] = 1 + return 1 + } + override fun close() { closed = true } + } + + assertSame(failure, assertThrows(IOException::class.java) { + copyToTemporaryMediaFile(temporary) { input } + }) + assertTrue(closed) + assertFalse(temporary.exists()) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun postProcessingAlwaysRetiresItsInputWithoutClaimingSourceOrReplacementFiles() { + val directory = Files.createTempDirectory("native-media-scope-test").toFile() + try { + val source = File(directory, "saved.flac").also { it.writeText("saved audio") } + val replacement = File(directory, "shared.flac").also { it.writeText("shared audio") } + for (response in listOf("success", "hook rejected", "output missing", "SAF write failed")) { + val temporary = File.createTempFile("saf_", ".flac", directory) + temporary.writeText("temporary audio") + assertEquals(response, withOwnedTemporaryMediaFile(temporary) { input -> + assertEquals("temporary audio", input.readText()) + response + }) + assertFalse(temporary.exists()) + } + + val temporary = File.createTempFile("saf_", ".flac", directory) + val failure = IllegalStateException("postProcess failed") + assertSame(failure, assertThrows(IllegalStateException::class.java) { + withOwnedTemporaryMediaFile(temporary) { throw failure } + }) + assertFalse(temporary.exists()) + assertEquals("saved audio", source.readText()) + assertEquals("shared audio", replacement.readText()) + } finally { + directory.deleteRecursively() + } + } +}