fix(android): release temporary media on failed SAF operations

This commit is contained in:
zarzet committed 2026-10-06 22:00:44 +07:00
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()
}
}
}