From 01c51bf75129ce033c3c33e14d914ccfef237dcb Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:02:47 +0700 Subject: [PATCH] refactor(android): move the SAF backend calls into MainActivitySafCalls.kt MainActivity.kt should be the dispatcher only. The backend `when` keeps every case label in the same order, so the routing set check and the dispatch order are unchanged, and each SAF case now calls a MainActivity.handle function in MainActivitySafCalls.kt. Bodies move verbatim; the only edit is this@MainActivity becoming the extension function's own receiver label. --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 347 +---------------- .../zarz/spotiflac/MainActivitySafCalls.kt | 363 ++++++++++++++++++ 2 files changed, 383 insertions(+), 327 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafCalls.kt 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 baf906fc..70e5bdf3 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -8,7 +8,6 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.os.ParcelFileDescriptor -import android.provider.DocumentsContract import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile @@ -533,291 +532,23 @@ class MainActivity: FlutterFragmentActivity() { setupFolderPickerStore.acknowledge(call.argument("payload") ?: "") result.success(null) } - "safExists" -> { - val uriStr = call.argument("uri") ?: "" - val exists = withContext(Dispatchers.IO) { - val uri = Uri.parse(uriStr) - DocumentFile.fromSingleUri(this@MainActivity, uri)?.exists() == true - } - result.success(exists) - } - "safExistsBatch" -> { - val urisJson = call.argument("uris_json") ?: "[]" - val response = withContext(Dispatchers.IO) { - safExistsBatch(urisJson) - } - result.success(response) - } - "probeSafTreeReadAccess" -> { - val uriStr = call.argument("tree_uri") ?: "" - val readable = withContext(Dispatchers.IO) { - probeSafTreeReadAccess( - hasReadPermission = { - val uri = Uri.parse(uriStr) - val root = DocumentsContract.buildDocumentUriUsingTree( - uri, - DocumentsContract.getTreeDocumentId(uri), - ) - checkUriPermission( - root, - android.os.Process.myPid(), - android.os.Process.myUid(), - Intent.FLAG_GRANT_READ_URI_PERMISSION, - ) == android.content.pm.PackageManager.PERMISSION_GRANTED - }, - readRoot = { - val uri = Uri.parse(uriStr) - val root = DocumentsContract.buildDocumentUriUsingTree( - uri, - DocumentsContract.getTreeDocumentId(uri), - ) - val rootPresent = contentResolver.query( - root, - arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), - null, null, null, - )?.use { cursor -> - when { - cursor.moveToFirst() -> true - cursor.extras.getBoolean(DocumentsContract.EXTRA_LOADING, false) -> null - else -> false - } - } - if (rootPresent == true) { - true - } else { - // A reconnecting remote provider may not have - // queryDocument metadata yet. A completed child - // listing is stronger evidence of read access. - val directory = DocumentFile.fromTreeUri(this@MainActivity, uri) - if (directory != null && listSafChildrenOrThrow(directory).isNotEmpty()) { - true - } else { - rootPresent - } - } - }, - onFailure = { error -> - android.util.Log.w("SpotiFLAC", "SAF library read probe failed", error) - }, - ) - } - result.success(readable) - } - "isSafTreeAccessible" -> { - val uriStr = call.argument("tree_uri") ?: "" - val accessible = withContext(Dispatchers.IO) { - try { - val uri = Uri.parse(uriStr) - val persisted = contentResolver.persistedUriPermissions.any { - it.uri == uri && it.isReadPermission && it.isWritePermission - } - if (!persisted) { - false - } else { - val doc = DocumentFile.fromTreeUri(this@MainActivity, uri) - doc != null && doc.exists() && doc.canWrite() - } - } catch (e: Exception) { - android.util.Log.w("SpotiFLAC", "SAF tree access check failed: ${e.message}") - false - } - } - result.success(accessible) - } - "safDelete" -> { - val uriStr = call.argument("uri") ?: "" - val deleted = withContext(Dispatchers.IO) { - val uri = Uri.parse(uriStr) - DocumentFile.fromSingleUri(this@MainActivity, uri)?.delete() == true - } - result.success(deleted) - } - "safStat" -> { - val uriStr = call.argument("uri") ?: "" - val response = withContext(Dispatchers.IO) { - querySafDocumentStat(contentResolver, Uri.parse(uriStr)).toString() - } - result.success(response) - } - "resolveSafFile" -> { - val treeUriStr = call.argument("tree_uri") ?: "" - val relativeDir = call.argument("relative_dir") ?: "" - val fileName = call.argument("file_name") ?: "" - val response = withContext(Dispatchers.IO) { - resolveSafFile(treeUriStr, relativeDir, fileName) - } - result.success(response) - } - "inspectSafFiles" -> { - val requestsJson = call.argument("requests_json") ?: "[]" - val response = withContext(Dispatchers.IO) { - inspectSafFiles(requestsJson) - } - result.success(response) - } - "safCopyToTemp" -> { - val uriStr = call.argument("uri") ?: "" - val tempPath = withContext(Dispatchers.IO) { - copyUriToTemp(Uri.parse(uriStr)) - } - result.success(tempPath) - } - "safOpenPlaybackLease" -> { - val uriStr = call.argument("uri") ?: "" - val lease = withContext(Dispatchers.IO) { - openSafPlaybackLease(uriStr) - } - result.success(lease) - } - "safClosePlaybackLease" -> { - val token = call.argument("token") ?: "" - closeSafPlaybackLease(token) - result.success(null) - } - "safCreateFromPath" -> { - val treeUriStr = call.argument("tree_uri") ?: "" - val relativeDir = call.argument("relative_dir") ?: "" - val fileName = SafDownloadHandler.sanitizeFilename(call.argument("file_name") ?: "") - val mimeType = call.argument("mime_type") ?: "application/octet-stream" - val srcPath = call.argument("src_path") ?: "" - val createdUri = withContext(Dispatchers.IO) { - if (treeUriStr.isBlank()) return@withContext null - if (fileName.isBlank()) return@withContext null - val dir = SafDownloadHandler.ensureDocumentDir(this@MainActivity, Uri.parse(treeUriStr), relativeDir) ?: return@withContext null - val existing = findSafChild(this@MainActivity, dir, fileName) - val createdNew = existing == null - val doc = SafDownloadHandler.createOrReuseDocumentFile(this@MainActivity, dir, mimeType, fileName) - ?: return@withContext null - if (!writeUriFromPath(doc.uri, srcPath)) { - if (createdNew) { - doc.delete() - } - return@withContext null - } - doc.uri.toString() - } - result.success(createdUri) - } - "safCreateIfAbsentFromPath" -> { - val treeUriStr = call.argument("tree_uri") ?: "" - val relativeDir = call.argument("relative_dir") ?: "" - val fileName = call.argument("file_name") ?: "" - val mimeType = call.argument("mime_type") ?: "application/octet-stream" - val srcPath = call.argument("src_path") ?: "" - val response = withContext(Dispatchers.IO) { - if (treeUriStr.isBlank() || fileName.isBlank()) return@withContext null - SafDownloadHandler.writeFileToSafIfAbsent( - context = this@MainActivity, - treeUriStr = treeUriStr, - relativeDir = relativeDir, - fileName = fileName, - mimeType = mimeType, - srcPath = srcPath, - )?.let { writeResult -> - JSONObject() - .put("uri", writeResult.uri) - .put("file_name", writeResult.fileName) - .put("already_exists", writeResult.alreadyExists) - .put("publish_timings_ms", JSONObject(writeResult.publishTimingsMs)) - .toString() - } - } - result.success(response) - } - "safCreateUniqueFromPath" -> { - val treeUriStr = call.argument("tree_uri") ?: "" - val relativeDir = call.argument("relative_dir") ?: "" - val fileName = call.argument("file_name") ?: "" - val mimeType = call.argument("mime_type") ?: "application/octet-stream" - val srcPath = call.argument("src_path") ?: "" - val preservedSuffix = call.argument("preserved_suffix") ?: "" - val response = withContext(Dispatchers.IO) { - if (treeUriStr.isBlank() || fileName.isBlank()) return@withContext null - SafDownloadHandler.writeFileToSafUnique( - context = this@MainActivity, - treeUriStr = treeUriStr, - relativeDir = relativeDir, - fileName = fileName, - mimeType = mimeType, - srcPath = srcPath, - preservedSuffix = preservedSuffix, - )?.let { writeResult -> - JSONObject() - .put("uri", writeResult.uri) - .put("file_name", writeResult.fileName) - .put("publish_timings_ms", JSONObject(writeResult.publishTimingsMs)) - .toString() - } - } - result.success(response) - } - "safCreateCollisionAwareFromPath" -> { - val treeUriStr = call.argument("tree_uri") ?: "" - val relativeDir = call.argument("relative_dir") ?: "" - val cleanFileName = call.argument("clean_file_name") ?: "" - val variantFileName = call.argument("variant_file_name") ?: "" - val mimeType = call.argument("mime_type") ?: "application/octet-stream" - val srcPath = call.argument("src_path") ?: "" - val preservedSuffix = call.argument("preserved_suffix") ?: "" - val response = withContext(Dispatchers.IO) { - if ( - treeUriStr.isBlank() || - cleanFileName.isBlank() || - variantFileName.isBlank() - ) return@withContext null - SafDownloadHandler.writeFileToSafCollisionAware( - context = this@MainActivity, - treeUriStr = treeUriStr, - relativeDir = relativeDir, - cleanFileName = cleanFileName, - variantFileName = variantFileName, - mimeType = mimeType, - srcPath = srcPath, - preservedSuffix = preservedSuffix, - )?.let { writeResult -> - JSONObject() - .put("uri", writeResult.uri) - .put("file_name", writeResult.fileName) - .put("publish_timings_ms", JSONObject(writeResult.publishTimingsMs)) - .toString() - } - } - result.success(response) - } - "openContentUri" -> { - val uriStr = call.argument("uri") ?: "" - val mimeType = call.argument("mime_type") ?: "" - try { - val uri = Uri.parse(uriStr) - val type = if (mimeType.isNotBlank()) mimeType else contentResolver.getType(uri) ?: "*/*" - val intent = Intent(Intent.ACTION_VIEW).setDataAndType(uri, type) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - startActivity(intent) - result.success(null) - } catch (e: Exception) { - result.error("open_failed", e.message, null) - } - } - "shareContentUri" -> { - val uriStr = call.argument("uri") ?: "" - val title = call.argument("title") ?: "" - try { - val uri = Uri.parse(uriStr) - val type = contentResolver.getType(uri) ?: "audio/*" - val shareIntent = Intent(Intent.ACTION_SEND).apply { - putExtra(Intent.EXTRA_STREAM, uri) - setType(type) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - if (title.isNotBlank()) { - putExtra(Intent.EXTRA_SUBJECT, title) - } - } - startActivity(Intent.createChooser(shareIntent, title.ifBlank { "Share" })) - result.success(true) - } catch (e: Exception) { - result.error("share_failed", e.message, null) - } - } + "safExists" -> handleSafExists(call, result) + "safExistsBatch" -> handleSafExistsBatch(call, result) + "probeSafTreeReadAccess" -> handleProbeSafTreeReadAccess(call, result) + "isSafTreeAccessible" -> handleIsSafTreeAccessible(call, result) + "safDelete" -> handleSafDelete(call, result) + "safStat" -> handleSafStat(call, result) + "resolveSafFile" -> handleResolveSafFile(call, result) + "inspectSafFiles" -> handleInspectSafFiles(call, result) + "safCopyToTemp" -> handleSafCopyToTemp(call, result) + "safOpenPlaybackLease" -> handleSafOpenPlaybackLease(call, result) + "safClosePlaybackLease" -> handleSafClosePlaybackLease(call, result) + "safCreateFromPath" -> handleSafCreateFromPath(call, result) + "safCreateIfAbsentFromPath" -> handleSafCreateIfAbsentFromPath(call, result) + "safCreateUniqueFromPath" -> handleSafCreateUniqueFromPath(call, result) + "safCreateCollisionAwareFromPath" -> handleSafCreateCollisionAwareFromPath(call, result) + "openContentUri" -> handleOpenContentUri(call, result) + "shareContentUri" -> handleShareContentUri(call, result) "rewriteSplitArtistTags" -> { val filePath = call.argument("file_path") ?: "" val artist = call.argument("artist") ?: "" @@ -969,40 +700,8 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - "writeTempToSaf" -> { - val tempPath = call.argument("temp_path") ?: "" - val safUri = call.argument("saf_uri") ?: "" - val response = withContext(Dispatchers.IO) { - try { - val uri = Uri.parse(safUri) - if (writeUriFromPath(uri, tempPath)) { - """{"success":true}""" - } else { - """{"success":false,"error":"Failed to write back to SAF"}""" - } - } finally { - try { File(tempPath).delete() } catch (_: Exception) {} - } - } - result.success(response) - } - "writeSafSidecarLrc" -> { - val safUri = call.argument("saf_uri") ?: "" - val lyrics = call.argument("lyrics") ?: "" - val response = withContext(Dispatchers.IO) { - try { - val uri = Uri.parse(safUri) - if (writeSafSidecarLrc(uri, lyrics)) { - """{"success":true}""" - } else { - """{"success":false,"error":"Failed to write LRC sidecar"}""" - } - } catch (e: Exception) { - """{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" - } - } - result.success(response) - } + "writeTempToSaf" -> handleWriteTempToSaf(call, result) + "writeSafSidecarLrc" -> handleWriteSafSidecarLrc(call, result) "downloadCoverToFile" -> { val coverUrl = call.argument("cover_url") ?: "" val outputPath = call.argument("output_path") ?: "" @@ -1328,13 +1027,7 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - "getSafFileModTimes" -> { - val uris = call.argument("uris") ?: "[]" - val response = withContext(Dispatchers.IO) { - getSafFileModTimes(uris) - } - result.success(response) - } + "getSafFileModTimes" -> handleGetSafFileModTimes(call, result) "getLibraryScanProgress" -> { val response = withContext(Dispatchers.IO) { if (safScanActive) { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafCalls.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafCalls.kt new file mode 100644 index 00000000..c56ea814 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafCalls.kt @@ -0,0 +1,363 @@ +package com.zarz.spotiflac + +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File + +// SAF backend calls: document existence, access probes, stat and lookup, +// temp copies, playback leases, document creation from a local file, +// content URI open/share, and writes back into SAF. MainActivity's backend +// `when` dispatches each case here, in the order it lists them. + +internal suspend fun MainActivity.handleSafExists(call: MethodCall, result: MethodChannel.Result) { + val uriStr = call.argument("uri") ?: "" + val exists = withContext(Dispatchers.IO) { + val uri = Uri.parse(uriStr) + DocumentFile.fromSingleUri(this@handleSafExists, uri)?.exists() == true + } + result.success(exists) +} + +internal suspend fun MainActivity.handleSafExistsBatch(call: MethodCall, result: MethodChannel.Result) { + val urisJson = call.argument("uris_json") ?: "[]" + val response = withContext(Dispatchers.IO) { + safExistsBatch(urisJson) + } + result.success(response) +} + +internal suspend fun MainActivity.handleProbeSafTreeReadAccess(call: MethodCall, result: MethodChannel.Result) { + val uriStr = call.argument("tree_uri") ?: "" + val readable = withContext(Dispatchers.IO) { + probeSafTreeReadAccess( + hasReadPermission = { + val uri = Uri.parse(uriStr) + val root = DocumentsContract.buildDocumentUriUsingTree( + uri, + DocumentsContract.getTreeDocumentId(uri), + ) + checkUriPermission( + root, + android.os.Process.myPid(), + android.os.Process.myUid(), + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + }, + readRoot = { + val uri = Uri.parse(uriStr) + val root = DocumentsContract.buildDocumentUriUsingTree( + uri, + DocumentsContract.getTreeDocumentId(uri), + ) + val rootPresent = contentResolver.query( + root, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), + null, null, null, + )?.use { cursor -> + when { + cursor.moveToFirst() -> true + cursor.extras.getBoolean(DocumentsContract.EXTRA_LOADING, false) -> null + else -> false + } + } + if (rootPresent == true) { + true + } else { + // A reconnecting remote provider may not have + // queryDocument metadata yet. A completed child + // listing is stronger evidence of read access. + val directory = DocumentFile.fromTreeUri(this@handleProbeSafTreeReadAccess, uri) + if (directory != null && listSafChildrenOrThrow(directory).isNotEmpty()) { + true + } else { + rootPresent + } + } + }, + onFailure = { error -> + android.util.Log.w("SpotiFLAC", "SAF library read probe failed", error) + }, + ) + } + result.success(readable) +} + +internal suspend fun MainActivity.handleIsSafTreeAccessible(call: MethodCall, result: MethodChannel.Result) { + val uriStr = call.argument("tree_uri") ?: "" + val accessible = withContext(Dispatchers.IO) { + try { + val uri = Uri.parse(uriStr) + val persisted = contentResolver.persistedUriPermissions.any { + it.uri == uri && it.isReadPermission && it.isWritePermission + } + if (!persisted) { + false + } else { + val doc = DocumentFile.fromTreeUri(this@handleIsSafTreeAccessible, uri) + doc != null && doc.exists() && doc.canWrite() + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "SAF tree access check failed: ${e.message}") + false + } + } + result.success(accessible) +} + +internal suspend fun MainActivity.handleSafDelete(call: MethodCall, result: MethodChannel.Result) { + val uriStr = call.argument("uri") ?: "" + val deleted = withContext(Dispatchers.IO) { + val uri = Uri.parse(uriStr) + DocumentFile.fromSingleUri(this@handleSafDelete, uri)?.delete() == true + } + result.success(deleted) +} + +internal suspend fun MainActivity.handleSafStat(call: MethodCall, result: MethodChannel.Result) { + val uriStr = call.argument("uri") ?: "" + val response = withContext(Dispatchers.IO) { + querySafDocumentStat(contentResolver, Uri.parse(uriStr)).toString() + } + result.success(response) +} + +internal suspend fun MainActivity.handleResolveSafFile(call: MethodCall, result: MethodChannel.Result) { + val treeUriStr = call.argument("tree_uri") ?: "" + val relativeDir = call.argument("relative_dir") ?: "" + val fileName = call.argument("file_name") ?: "" + val response = withContext(Dispatchers.IO) { + resolveSafFile(treeUriStr, relativeDir, fileName) + } + result.success(response) +} + +internal suspend fun MainActivity.handleInspectSafFiles(call: MethodCall, result: MethodChannel.Result) { + val requestsJson = call.argument("requests_json") ?: "[]" + val response = withContext(Dispatchers.IO) { + inspectSafFiles(requestsJson) + } + result.success(response) +} + +internal suspend fun MainActivity.handleSafCopyToTemp(call: MethodCall, result: MethodChannel.Result) { + val uriStr = call.argument("uri") ?: "" + val tempPath = withContext(Dispatchers.IO) { + copyUriToTemp(Uri.parse(uriStr)) + } + result.success(tempPath) +} + +internal suspend fun MainActivity.handleSafOpenPlaybackLease(call: MethodCall, result: MethodChannel.Result) { + val uriStr = call.argument("uri") ?: "" + val lease = withContext(Dispatchers.IO) { + openSafPlaybackLease(uriStr) + } + result.success(lease) +} + +internal fun MainActivity.handleSafClosePlaybackLease(call: MethodCall, result: MethodChannel.Result) { + val token = call.argument("token") ?: "" + closeSafPlaybackLease(token) + result.success(null) +} + +internal suspend fun MainActivity.handleSafCreateFromPath(call: MethodCall, result: MethodChannel.Result) { + val treeUriStr = call.argument("tree_uri") ?: "" + val relativeDir = call.argument("relative_dir") ?: "" + val fileName = SafDownloadHandler.sanitizeFilename(call.argument("file_name") ?: "") + val mimeType = call.argument("mime_type") ?: "application/octet-stream" + val srcPath = call.argument("src_path") ?: "" + val createdUri = withContext(Dispatchers.IO) { + if (treeUriStr.isBlank()) return@withContext null + if (fileName.isBlank()) return@withContext null + val dir = SafDownloadHandler.ensureDocumentDir(this@handleSafCreateFromPath, Uri.parse(treeUriStr), relativeDir) ?: return@withContext null + val existing = findSafChild(this@handleSafCreateFromPath, dir, fileName) + val createdNew = existing == null + val doc = SafDownloadHandler.createOrReuseDocumentFile(this@handleSafCreateFromPath, dir, mimeType, fileName) + ?: return@withContext null + if (!writeUriFromPath(doc.uri, srcPath)) { + if (createdNew) { + doc.delete() + } + return@withContext null + } + doc.uri.toString() + } + result.success(createdUri) +} + +internal suspend fun MainActivity.handleSafCreateIfAbsentFromPath(call: MethodCall, result: MethodChannel.Result) { + val treeUriStr = call.argument("tree_uri") ?: "" + val relativeDir = call.argument("relative_dir") ?: "" + val fileName = call.argument("file_name") ?: "" + val mimeType = call.argument("mime_type") ?: "application/octet-stream" + val srcPath = call.argument("src_path") ?: "" + val response = withContext(Dispatchers.IO) { + if (treeUriStr.isBlank() || fileName.isBlank()) return@withContext null + SafDownloadHandler.writeFileToSafIfAbsent( + context = this@handleSafCreateIfAbsentFromPath, + treeUriStr = treeUriStr, + relativeDir = relativeDir, + fileName = fileName, + mimeType = mimeType, + srcPath = srcPath, + )?.let { writeResult -> + JSONObject() + .put("uri", writeResult.uri) + .put("file_name", writeResult.fileName) + .put("already_exists", writeResult.alreadyExists) + .put("publish_timings_ms", JSONObject(writeResult.publishTimingsMs)) + .toString() + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleSafCreateUniqueFromPath(call: MethodCall, result: MethodChannel.Result) { + val treeUriStr = call.argument("tree_uri") ?: "" + val relativeDir = call.argument("relative_dir") ?: "" + val fileName = call.argument("file_name") ?: "" + val mimeType = call.argument("mime_type") ?: "application/octet-stream" + val srcPath = call.argument("src_path") ?: "" + val preservedSuffix = call.argument("preserved_suffix") ?: "" + val response = withContext(Dispatchers.IO) { + if (treeUriStr.isBlank() || fileName.isBlank()) return@withContext null + SafDownloadHandler.writeFileToSafUnique( + context = this@handleSafCreateUniqueFromPath, + treeUriStr = treeUriStr, + relativeDir = relativeDir, + fileName = fileName, + mimeType = mimeType, + srcPath = srcPath, + preservedSuffix = preservedSuffix, + )?.let { writeResult -> + JSONObject() + .put("uri", writeResult.uri) + .put("file_name", writeResult.fileName) + .put("publish_timings_ms", JSONObject(writeResult.publishTimingsMs)) + .toString() + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleSafCreateCollisionAwareFromPath(call: MethodCall, result: MethodChannel.Result) { + val treeUriStr = call.argument("tree_uri") ?: "" + val relativeDir = call.argument("relative_dir") ?: "" + val cleanFileName = call.argument("clean_file_name") ?: "" + val variantFileName = call.argument("variant_file_name") ?: "" + val mimeType = call.argument("mime_type") ?: "application/octet-stream" + val srcPath = call.argument("src_path") ?: "" + val preservedSuffix = call.argument("preserved_suffix") ?: "" + val response = withContext(Dispatchers.IO) { + if ( + treeUriStr.isBlank() || + cleanFileName.isBlank() || + variantFileName.isBlank() + ) return@withContext null + SafDownloadHandler.writeFileToSafCollisionAware( + context = this@handleSafCreateCollisionAwareFromPath, + treeUriStr = treeUriStr, + relativeDir = relativeDir, + cleanFileName = cleanFileName, + variantFileName = variantFileName, + mimeType = mimeType, + srcPath = srcPath, + preservedSuffix = preservedSuffix, + )?.let { writeResult -> + JSONObject() + .put("uri", writeResult.uri) + .put("file_name", writeResult.fileName) + .put("publish_timings_ms", JSONObject(writeResult.publishTimingsMs)) + .toString() + } + } + result.success(response) +} + +internal fun MainActivity.handleOpenContentUri(call: MethodCall, result: MethodChannel.Result) { + val uriStr = call.argument("uri") ?: "" + val mimeType = call.argument("mime_type") ?: "" + try { + val uri = Uri.parse(uriStr) + val type = if (mimeType.isNotBlank()) mimeType else contentResolver.getType(uri) ?: "*/*" + val intent = Intent(Intent.ACTION_VIEW).setDataAndType(uri, type) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + startActivity(intent) + result.success(null) + } catch (e: Exception) { + result.error("open_failed", e.message, null) + } +} + +internal fun MainActivity.handleShareContentUri(call: MethodCall, result: MethodChannel.Result) { + val uriStr = call.argument("uri") ?: "" + val title = call.argument("title") ?: "" + try { + val uri = Uri.parse(uriStr) + val type = contentResolver.getType(uri) ?: "audio/*" + val shareIntent = Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_STREAM, uri) + setType(type) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (title.isNotBlank()) { + putExtra(Intent.EXTRA_SUBJECT, title) + } + } + startActivity(Intent.createChooser(shareIntent, title.ifBlank { "Share" })) + result.success(true) + } catch (e: Exception) { + result.error("share_failed", e.message, null) + } +} + +internal suspend fun MainActivity.handleWriteTempToSaf(call: MethodCall, result: MethodChannel.Result) { + val tempPath = call.argument("temp_path") ?: "" + val safUri = call.argument("saf_uri") ?: "" + val response = withContext(Dispatchers.IO) { + try { + val uri = Uri.parse(safUri) + if (writeUriFromPath(uri, tempPath)) { + """{"success":true}""" + } else { + """{"success":false,"error":"Failed to write back to SAF"}""" + } + } finally { + try { File(tempPath).delete() } catch (_: Exception) {} + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleWriteSafSidecarLrc(call: MethodCall, result: MethodChannel.Result) { + val safUri = call.argument("saf_uri") ?: "" + val lyrics = call.argument("lyrics") ?: "" + val response = withContext(Dispatchers.IO) { + try { + val uri = Uri.parse(safUri) + if (writeSafSidecarLrc(uri, lyrics)) { + """{"success":true}""" + } else { + """{"success":false,"error":"Failed to write LRC sidecar"}""" + } + } catch (e: Exception) { + """{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) +} + +internal suspend fun MainActivity.handleGetSafFileModTimes(call: MethodCall, result: MethodChannel.Result) { + val uris = call.argument("uris") ?: "[]" + val response = withContext(Dispatchers.IO) { + getSafFileModTimes(uris) + } + result.success(response) +}