mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
fix(android): release temporary media on failed SAF operations
This commit is contained in:
1 parent
4228791c13
commit
ca747f8ce2
12 files changed
+161
-244
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <T> 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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user