(WIP)Android SAF

This commit is contained in:
shabinder 2021-05-14 02:51:33 +05:30
parent 96fdd52ef4
commit ea48d929a4
5 changed files with 125 additions and 292 deletions

View File

@ -46,7 +46,6 @@ import com.arkivanov.decompose.extensions.compose.jetbrains.rememberRootComponen
import com.arkivanov.mvikotlin.logging.store.LoggingStoreFactory
import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory
import com.github.k1rakishou.fsaf.FileChooser
import com.github.k1rakishou.fsaf.FileManager
import com.github.k1rakishou.fsaf.callback.directory.DirectoryChooserCallback
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsPadding
@ -58,7 +57,6 @@ import com.shabinder.common.models.Actions
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.PlatformActions
import com.shabinder.common.models.PlatformActions.Companion.SharedPreferencesKey
import com.shabinder.common.models.SpotiFlyerBaseDir
import com.shabinder.common.models.Status
import com.shabinder.common.models.TrackDetails
import com.shabinder.common.models.methods
@ -148,34 +146,13 @@ class MainActivity : ComponentActivity() {
@Suppress("DEPRECATION")
private fun setUpOnPrefClickListener() {
/*Get User Permission to access External SD*//*
/*Get User Permission to access External SD*/
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
}
startActivityForResult(intent, externalSDWriteAccess)*/
val fileChooser = FileChooser(applicationContext)
fileChooser.openChooseDirectoryDialog(object : DirectoryChooserCallback() {
override fun onResult(uri: Uri) {
println("treeUri = $uri")
// Can be only used using SAF
contentResolver.takePersistableUriPermission(uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val treeDocumentFile = DocumentFile.fromTreeUri(applicationContext, uri)
dir.setDownloadDirectory(uri)
showPopUpMessage("New Download Directory Set")
GlobalScope.launch {
dir.createDirectories()
}
}
override fun onCancel(reason: String) {
println("Canceled by user")
}
})
startActivityForResult(intent, externalSDWriteAccess)
}
private fun showPopUpMessage(string: String, long: Boolean = false) {
@ -327,24 +304,19 @@ class MainActivity : ComponentActivity() {
externalSDWriteAccess -> {
// Can be only used using SAF
/*if (resultCode == RESULT_OK) {
if (resultCode == RESULT_OK) {
val treeUri: Uri? = data?.data
if (treeUri == null){
if (treeUri == null) {
showPopUpMessage("Some Error Occurred While Setting New Download Directory")
}else {
// Persistently save READ & WRITE Access to whole Selected Directory Tree
contentResolver.takePersistableUriPermission(treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
dir.setDownloadDirectory(com.shabinder.common.models.File(
DocumentFile.fromTreeUri(applicationContext,treeUri)?.createDirectory("SpotiFlyer")!!)
)
dir.setDownloadDirectory(treeUri)
showPopUpMessage("New Download Directory Set")
GlobalScope.launch {
dir.createDirectories()
}
}
}*/
}
}
}

View File

@ -16,17 +16,16 @@
package com.shabinder.common.di
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Environment
import android.util.Log
import android.provider.MediaStore
import androidx.compose.ui.graphics.asImageBitmap
import androidx.documentfile.provider.DocumentFile
import co.touchlab.kermit.Kermit
import com.github.k1rakishou.fsaf.FileManager
import com.github.k1rakishou.fsaf.file.AbstractFile
import com.github.k1rakishou.fsaf.file.DirectorySegment
import com.github.k1rakishou.fsaf.file.FileSegment
import com.github.k1rakishou.fsaf.manager.base_directory.BaseDirectory
@ -45,13 +44,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
/*
* Ignore Deprecation
* Deprecation is only a Suggestion P-)
@ -60,22 +59,32 @@ import java.net.URL
actual class Dir actual constructor(
private val logger: Kermit,
private val settings: Settings,
private val spotiFlyerDatabase: SpotiFlyerDatabase,
spotiFlyerDatabase: SpotiFlyerDatabase,
): KoinComponent {
private val context: Context = get()
val fileManager = FileManager(context)
init {
fileManager.registerBaseDir<SpotiFlyerBaseDir>(SpotiFlyerBaseDir({ getDirType() },
fileManager.apply {
registerBaseDir<SpotiFlyerBaseDir>(SpotiFlyerBaseDir({ getDirType() },
getJavaFile = {
java.io.File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC).toString()
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC)
.toString()
+ "/SpotiFlyer/"
)
},
getSAFUri = { null }
getSAFUri = {
settings.getStringOrNull(DirKey)?.let {
Uri.parse(it)
}
}
))
defaultDir().documentFile?.let {
createSnapshot(it,true)
}
}
}
companion object {
@ -105,11 +114,13 @@ actual class Dir actual constructor(
actual fun setDownloadDirectory(newBasePath:File) = settings.putString(
DirKey,
newBasePath.documentFile?.getFullPath()!!
newBasePath.documentFile!!.getFullPath()
)
fun setDownloadDirectory(treeUri:Uri) {
fileManager.registerBaseDir<SpotiFlyerBaseDir>(SpotiFlyerBaseDir(
try {
fileManager.apply {
registerBaseDir<SpotiFlyerBaseDir>(SpotiFlyerBaseDir(
{ getDirType() },
getJavaFile = {
null
@ -118,13 +129,16 @@ actual class Dir actual constructor(
treeUri
}
))
fromUri(treeUri)?.let { createSnapshot(it,true) }
}
} catch (e:IllegalArgumentException) {
methods.value.showPopUpMessage("This Directory is already set as Download Directory")
}
GlobalScope.launch {
setDownloadDirectory(File(fileManager.fromUri(treeUri)))
createDirectories()
}
}
@Suppress("DEPRECATION")// By Default Save Files to /Music/SpotiFlyer/
private val defaultBaseDir = SpotiFlyerBaseDir({ getDirType() },
getJavaFile = {java.io.File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC).toString() + "/SpotiFlyer/")},
getSAFUri = { null }
)
// Image Cache Path
// We Will Handling Image relating operations using java.io.File (reason: Faster)
@ -145,25 +159,9 @@ actual class Dir actual constructor(
fileManager.create(dirPath.documentFile!!)
}
}
/*try {
val yourAppDir = File(dirPath)
if (!yourAppDir.exists() && !yourAppDir.isDirectory) { // create empty directory
if (yourAppDir.mkdirs()) { logger.i { "$dirPath created" } } else {
logger.e { "Unable to create Dir: $dirPath!" }
}
} else {
logger.i { "$dirPath already exists" }
}
} catch (e: SecurityException) {
//TRY USING SAF
Log.d("Directory","USING SAF to create $dirPath")
val file = DocumentFile.fromTreeUri(context, Uri.parse(defaultDir()))
DocumentFile.fromFile()
}*/
}
@Suppress("unused")
actual suspend fun clearCache(): Unit = withContext(dispatcherIO) {
try {
java.io.File(imageCachePath).deleteRecursively()
@ -178,20 +176,20 @@ actual class Dir actual constructor(
trackDetails: TrackDetails,
postProcess: (track: TrackDetails) -> Unit,
): Unit = withContext(dispatcherIO) {
val songFile = java.io.File(imageCachePath+"Tracks/"+ removeIllegalChars(trackDetails.title) + ".mp3")
val mediaFile = java.io.File(imageCachePath+"Tracks/"+ removeIllegalChars(trackDetails.title) + ".mp3")
try {
/*Make intermediate Dirs if they don't exist yet*/
if(!songFile.exists()) {
songFile.parentFile?.mkdirs()
if(!mediaFile.exists()) {
mediaFile.parentFile?.mkdirs()
}
// Write Bytes to Media File
mediaFile.writeBytes(mp3ByteArray)
if(mp3ByteArray.isNotEmpty()) songFile.writeBytes(mp3ByteArray)
Mp3File(songFile)
// Add Metadata to Media File
Mp3File(mediaFile)
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails,songFile.absolutePath)
.setId3v2TagsAndSaveFile(trackDetails,mediaFile.absolutePath)
// Copy File to Desired Location
val documentFile = when(getDirType()){
@ -201,105 +199,52 @@ actual class Dir actual constructor(
BaseDirectory.ActiveBaseDirType.JavaFileBaseDir -> {
fileManager.fromPath(trackDetails.outputFilePath)
}
}.also { fileManager.create(it!!) }
}.also {
// Create Desired File if it doesn't exists yet
fileManager.create(it!!)
}
try {
fileManager.copyFileContents(
fileManager.fromRawFile(songFile),
fileManager.fromRawFile(mediaFile),
documentFile!!
)
songFile.deleteOnExit()
/*val inStream = FileInputStream(songFile)
val buffer = ByteArray(1024)
var readLen: Int
while (inStream.read(buffer).also { readLen = it } != -1) {
outStream?.write(buffer, 0, readLen)
}
inStream.close()
// write the output file (You have now copied the file)
outStream?.flush()
outStream?.close()*/
mediaFile.deleteOnExit()
}catch (e:Exception) {
e.printStackTrace()
}
documentFile?.let {
addToLibrary(File(it))
addToLibrary(File(it),trackDetails)
}
/*when (trackDetails.outputFilePath.substringAfterLast('.')) {
".mp3" -> {
Mp3File(songFile)
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails,songFile.absolutePath)
// Copy File to DocumentUri
val documentFile = DocumentFile.fromSingleUri(context,Uri.parse(trackDetails.outputFilePath))
try {
val outStream = context.contentResolver.openOutputStream(documentFile?.uri!!)
val inStream = FileInputStream(songFile)
val buffer = ByteArray(1024)
var readLen: Int
while (inStream.read(buffer).also { readLen = it } != -1) {
outStream?.write(buffer, 0, readLen)
}
inStream.close()
// write the output file (You have now copied the file)
outStream?.flush()
outStream?.close()
}catch (e:Exception) {
e.printStackTrace()
}
documentFile?.let {
addToLibrary(File(it))
}
}
".m4a" -> {
*//*FFmpeg.executeAsync(
"-i ${m4aFile.absolutePath} -y -b:a 160k -acodec libmp3lame -vn ${m4aFile.absolutePath.substringBeforeLast('.') + ".mp3"}"
){ _, returnCode ->
when (returnCode) {
Config.RETURN_CODE_SUCCESS -> {
//FFMPEG task Completed
logger.d{ "Async command execution completed successfully." }
scope.launch {
Mp3File(File(m4aFile.absolutePath.substringBeforeLast('.') + ".mp3"))
.removeAllTags()
.setId3v1Tags(trackDetails)
.setId3v2TagsAndSaveFile(trackDetails)
addToLibrary(m4aFile.absolutePath.substringBeforeLast('.') + ".mp3")
}
}
Config.RETURN_CODE_CANCEL -> {
logger.d{"Async command execution cancelled by user."}
}
else -> {
logger.d { "Async command execution failed with rc=$returnCode" }
}
}
}*//*
}
else -> {
// TODO
}
}*/
}catch (e:Exception){
withContext(Dispatchers.Main){
//Toast.makeText(appContext,"Could Not Create File:\n${songFile.absolutePath}",Toast.LENGTH_SHORT).show()
}
if(songFile.exists()) songFile.delete()
logger.e { "${songFile.absolutePath} could not be created" }
e.printStackTrace()
if(mediaFile.exists()) mediaFile.delete()
logger.e { "${mediaFile.absolutePath} could not be created" }
}
}
actual fun addToLibrary(file: File) {
// methods.value.platformActions.addToLibrary(path)
actual fun addToLibrary(file: File,track: TrackDetails) {
try {
when (getDirType()) {
BaseDirectory.ActiveBaseDirType.SafBaseDir -> {
val values = ContentValues(4).apply {
put(MediaStore.Audio.Media.TITLE, track.title)
put(MediaStore.Audio.Media.DISPLAY_NAME, track.title)
put(MediaStore.Audio.Media.DATE_ADDED,
(System.currentTimeMillis() / 1000).toInt())
put(MediaStore.Audio.Media.MIME_TYPE, "audio/mpeg")
}
context.contentResolver.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
values)
}
BaseDirectory.ActiveBaseDirType.JavaFileBaseDir -> {
file.documentFile?.getFullPath()?.let {
methods.value.platformActions.addToLibrary(it)
}
}
}
} catch (e:Exception) { e.printStackTrace() }
}
actual suspend fun loadImage(url: String): Picture = withContext(dispatcherIO){
@ -319,6 +264,7 @@ actual class Dir actual constructor(
}
}
@Suppress("BlockingMethodInNonBlockingContext")
actual suspend fun cacheImage(image: Any, path: String):Unit = withContext(dispatcherIO) {
try {
java.io.File(path).parentFile?.mkdirs()
@ -330,6 +276,7 @@ actual class Dir actual constructor(
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun freshImage(url: String): Bitmap? = withContext(dispatcherIO) {
try {
val source = URL(url)
@ -372,59 +319,17 @@ actual class Dir actual constructor(
subFolder: String,
extension: String,
):File {
// Create Intermediate Directories
val file = fileManager.create(
defaultDir().documentFile!!, //Base Dir
DirectorySegment(removeIllegalChars(type)),
DirectorySegment(removeIllegalChars(subFolder)),
FileSegment(removeIllegalChars(itemName) + extension)
)
return File(file?.clone(FileSegment(removeIllegalChars(itemName) + extension)))/*.also {
if(fileManager.getLength(it.documentFile!!) == 0L){
fileManager.delete(it.documentFile!!)
}
}*/
/*GlobalScope.launch {
// Create Intermediate Directories
var file = defaultDir().documentFile
file = file.findFile(removeIllegalChars(type))
?: file.createDirectory(removeIllegalChars(type))
?: throw Exception("Couldn't Find/Create $type Directory")
if (subFolder.isNotEmpty()) file.findFile(removeIllegalChars(subFolder))
?: file.createDirectory(removeIllegalChars(subFolder))
?: throw Exception("Couldn't Find/Create $subFolder Directory")
}
val sep = "%2F"
val finalUri = defaultDir().documentFile.uri.toString() + sep +
removeIllegalChars(type) + sep +
removeIllegalChars(subFolder) + sep +
removeIllegalChars(itemName) + extension
return File(
DocumentFile.fromSingleUri(context,Uri.parse(finalUri))!!
).also {
Log.d("Final Output File",it.documentFile.uri.toString())
}*/
/*file = file?.findFile(removeIllegalChars(type))
?: file?.createDirectory(removeIllegalChars(type))
?: throw Exception("Couldn't Find/Create $type Directory")
if (subFolder.isNotEmpty()) file = file.findFile(removeIllegalChars(subFolder))
?: file.createDirectory(removeIllegalChars(subFolder))
?: throw Exception("Couldn't Find/Create $subFolder Directory")
// TODO check Mime
file = file.findFile(removeIllegalChars(itemName))
?: file.createFile("audio/mpeg",removeIllegalChars(itemName))
?: throw Exception("Couldn't Find/Create ${removeIllegalChars(itemName) + extension} File")
Log.d("Final Output File",file.uri.toString())
return File(file).also {
val size = it.documentFile.length()
Log.d("File size", size.toString())
if(size == 0L) it.documentFile.delete()
}*/
if(fileManager.getLength(it.documentFile!!) == 0L) fileManager.delete(it.documentFile!!)
}
//?.clone(FileSegment(removeIllegalChars(itemName) + extension)))
}
}

View File

@ -18,39 +18,31 @@ package com.shabinder.common.di.worker
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_CANCEL_CURRENT
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import co.touchlab.kermit.Kermit
import com.github.k1rakishou.fsaf.FileManager
import com.github.k1rakishou.fsaf.file.AbstractFile
import com.shabinder.common.di.*
import com.shabinder.common.di.providers.getData
import com.shabinder.common.di.utils.ParallelExecutor
import com.shabinder.common.models.DownloadResult
import com.shabinder.common.models.DownloadStatus
import com.shabinder.common.models.Status
import com.shabinder.common.models.TrackDetails
import com.shabinder.downloader.models.formats.Format
import com.shabinder.common.models.Status
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
@ -128,8 +120,7 @@ class ForegroundService : Service(), CoroutineScope {
val downloadObjects: ArrayList<TrackDetails>? = (
it.getParcelableArrayListExtra("object") ?: it.extras?.getParcelableArrayList(
"object"
)
"object")
)
downloadObjects?.let { list ->
@ -216,14 +207,12 @@ class ForegroundService : Service(), CoroutineScope {
is DownloadResult.Error -> {
launch {
logger.d(tag) { it.message }
/*logger.d(tag) { "${track.title} Requesting Download thru Android DM" }
downloadUsingDM(url, track.outputFilePath, track)*/
removeFromNotification("Downloading ${track.title}")
failed++
}
updateNotification()
sendTrackBroadcast(Status.FAILED.name,track)
}
}
is DownloadResult.Progress -> {
allTracksStatus[track.title] = DownloadStatus.Downloading(it.progress)
@ -252,14 +241,10 @@ class ForegroundService : Service(), CoroutineScope {
}
logger.d(tag) { "${track.title} Download Completed" }
downloaded++
} catch (
e: Exception
) {
// Try downloading using android DM
} catch (e: Exception) {
// Download Failed
logger.d(tag) { "${track.title} Download Failed! Error:Fetch!!!!" }
failed++
/*logger.d(tag) { "${track.title} Requesting Download thru Android DM" }
downloadUsingDM(url, track.outputFilePath, track)*/
}
removeFromNotification("Downloading ${track.title}")
}
@ -267,54 +252,6 @@ class ForegroundService : Service(), CoroutineScope {
}
}
/**
* If Custom Downloader Fails , Android Download Manager To RESCUE!!
**/
private fun downloadUsingDM(url: String, outputDir: String, track: TrackDetails) {
launch {
val uri = Uri.parse(url)
val request = DownloadManager.Request(uri).apply {
setAllowedNetworkTypes(
DownloadManager.Request.NETWORK_WIFI or
DownloadManager.Request.NETWORK_MOBILE
)
setAllowedOverRoaming(false)
setTitle(track.title)
setDescription("Spotify Downloader Working Up here...")
setDestinationUri(File(outputDir).toUri())
setNotificationVisibility(VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
}
// Start Download
val downloadID = downloadManager.enqueue(request)
logger.d("DownloadManager") { "Download Request Sent" }
val onDownloadComplete: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Fetching the download id received with the broadcast
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
// Checking if the received broadcast is for our enqueued download by matching download id
if (downloadID == id) {
allTracksStatus[track.title] = DownloadStatus.Converting
launch { dir.saveFileWithMetadata(byteArrayOf(), track){}; converted++ }
// Unregister this broadcast Receiver
this@ForegroundService.unregisterReceiver(this)
}
}
}
registerReceiver(onDownloadComplete, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}
}
/**
* This is the method that can be called to update the Notification
*/
private fun updateNotification() {
val mNotificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
mNotificationManager.notify(notificationId, getNotification())
}
private fun releaseWakeLock() {
logger.d(tag) { "Releasing Wake Lock" }
try {
@ -341,13 +278,17 @@ class ForegroundService : Service(), CoroutineScope {
service.createNotificationChannel(channel)
}
/*
* Time To Wrap UP
* - `Clean Up` and `Stop this Foreground Service`
* */
private fun killService() {
launch {
logger.d(tag) { "Killing Self" }
messageList = mutableListOf("Cleaning And Exiting", "", "", "", "")
downloadService.close()
updateNotification()
// dir.defaultDir().documentFile?.let { cleanFiles(it,dir.fileManager,logger) }
dir.defaultDir().documentFile?.let { cleanFiles(it,dir.fileManager,logger) }
cleanFiles(File(dir.imageCachePath + "Tracks/"),logger)
messageList = mutableListOf("", "", "", "", "")
releaseWakeLock()
@ -375,6 +316,9 @@ class ForegroundService : Service(), CoroutineScope {
}
}
/*
* Create A New Notification with all the updated data
* */
private fun getNotification(): Notification = NotificationCompat.Builder(this, channelId).run {
setSmallIcon(R.drawable.ic_download_arrow)
setContentTitle("Total: $total Completed:$converted Failed:$failed")
@ -402,6 +346,15 @@ class ForegroundService : Service(), CoroutineScope {
updateNotification()
}
/**
* This is the method that can be called to update the Notification
*/
private fun updateNotification() {
val mNotificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
mNotificationManager.notify(notificationId, getNotification())
}
private fun sendTrackBroadcast(action: String, track: TrackDetails) {
val intent = Intent().apply {
setAction(action)

View File

@ -37,7 +37,10 @@ fun cleanFiles(directory: AbstractFile,fm: FileManager,logger: Kermit) {
if (fm.isDirectory(file)) {
cleanFiles(file, fm, logger)
} else if (fm.isFile(file)) {
if (file.getFullPath().substringAfterLast(".") != "mp3") {
if (file.getFullPath().substringAfterLast(".") != "mp3"
||
fm.getLength(file) == 0L
) {
logger.d("Files Cleaning") { "Cleaning ${file.getFullPath()}" }
fm.delete(file)
}

View File

@ -56,7 +56,7 @@ expect class Dir (
suspend fun loadImage(url: String): Picture
suspend fun clearCache()
suspend fun saveFileWithMetadata(mp3ByteArray: ByteArray, trackDetails: TrackDetails,postProcess:(track: TrackDetails)->Unit = {})
fun addToLibrary(file: File)
fun addToLibrary(file: File,track: TrackDetails)
fun finalOutputFile(itemName: String, type: String, subFolder: String, extension: String = ".mp3"): File
fun finalOutputPath(itemName: String, type: String, subFolder: String, extension: String = ".mp3"): String
}