mirror of
https://github.com/Michatec/Radio.git
synced 2026-01-30 23:17:21 +00:00
496 lines
17 KiB
Kotlin
496 lines
17 KiB
Kotlin
/*
|
|
* FileHelper.kt
|
|
* Implements the FileHelper object
|
|
* A FileHelper provides helper methods for reading and writing files from and to device storage
|
|
*
|
|
* This file is part of
|
|
* TRANSISTOR - Radio App for Android
|
|
*
|
|
* Copyright (c) 2015-22 - Y20K.org
|
|
* Licensed under the MIT-License
|
|
* http://opensource.org/licenses/MIT
|
|
*/
|
|
|
|
|
|
package com.michatec.radio.helpers
|
|
|
|
import android.app.Activity
|
|
import android.content.Context
|
|
import android.database.Cursor
|
|
import android.graphics.Bitmap
|
|
import android.net.Uri
|
|
import android.provider.OpenableColumns
|
|
import android.util.Log
|
|
import androidx.core.content.FileProvider
|
|
import androidx.core.net.toUri
|
|
import com.google.gson.Gson
|
|
import com.google.gson.GsonBuilder
|
|
import com.michatec.radio.Keys
|
|
import com.michatec.radio.core.Collection
|
|
import com.michatec.radio.core.Station
|
|
import kotlinx.coroutines.*
|
|
import kotlinx.coroutines.Dispatchers.IO
|
|
import java.io.*
|
|
import java.util.*
|
|
import kotlin.coroutines.resume
|
|
import kotlin.coroutines.suspendCoroutine
|
|
|
|
|
|
/*
|
|
* FileHelper object
|
|
*/
|
|
object FileHelper {
|
|
|
|
|
|
/* Define log tag */
|
|
private val TAG: String = FileHelper::class.java.simpleName
|
|
|
|
|
|
/* Get file size for given Uri */
|
|
fun getFileSize(context: Context, uri: Uri): Long {
|
|
val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null)
|
|
return if (cursor != null) {
|
|
val sizeIndex: Int = cursor.getColumnIndex(OpenableColumns.SIZE)
|
|
cursor.moveToFirst()
|
|
val size: Long = cursor.getLong(sizeIndex)
|
|
cursor.close()
|
|
size
|
|
} else {
|
|
0L
|
|
}
|
|
}
|
|
|
|
|
|
/* Get file name for given Uri */
|
|
fun getFileName(context: Context, uri: Uri): String {
|
|
val cursor: Cursor? = context.contentResolver.query(uri, null, null, null, null)
|
|
return if (cursor != null) {
|
|
val nameIndex: Int = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
|
cursor.moveToFirst()
|
|
val name: String = cursor.getString(nameIndex)
|
|
cursor.close()
|
|
name
|
|
} else {
|
|
String()
|
|
}
|
|
}
|
|
|
|
|
|
/* Get content type for given file */
|
|
fun getContentType(context: Context, uri: Uri): String {
|
|
// get file type from content resolver
|
|
var contentType: String = context.contentResolver.getType(uri) ?: Keys.MIME_TYPE_UNSUPPORTED
|
|
contentType = contentType.lowercase(Locale.getDefault())
|
|
return if (contentType != Keys.MIME_TYPE_UNSUPPORTED && !contentType.contains(Keys.MIME_TYPE_OCTET_STREAM)) {
|
|
// return the found content type
|
|
contentType
|
|
} else {
|
|
// fallback: try to determine file type based on file extension
|
|
getContentTypeFromExtension(getFileName(context, uri))
|
|
}
|
|
}
|
|
|
|
|
|
/* Determine content type based on file extension */
|
|
fun getContentTypeFromExtension(fileName: String): String {
|
|
Log.i(TAG, "Deducing content type from file name: $fileName")
|
|
if (fileName.endsWith("m3u", true)) return Keys.MIME_TYPE_M3U
|
|
if (fileName.endsWith("pls", true)) return Keys.MIME_TYPE_PLS
|
|
if (fileName.endsWith("png", true)) return Keys.MIME_TYPE_PNG
|
|
if (fileName.endsWith("jpg", true)) return Keys.MIME_TYPE_JPG
|
|
if (fileName.endsWith("jpeg", true)) return Keys.MIME_TYPE_JPG
|
|
// default return
|
|
return Keys.MIME_TYPE_UNSUPPORTED
|
|
}
|
|
|
|
|
|
/* Determines a destination folder */
|
|
fun determineDestinationFolderPath(type: Int, stationUuid: String): String {
|
|
val folderPath: String = when (type) {
|
|
Keys.FILE_TYPE_PLAYLIST -> Keys.FOLDER_TEMP
|
|
Keys.FILE_TYPE_AUDIO -> Keys.FOLDER_AUDIO + "/" + stationUuid
|
|
Keys.FILE_TYPE_IMAGE -> Keys.FOLDER_IMAGES + "/" + stationUuid
|
|
else -> "/"
|
|
}
|
|
return folderPath
|
|
}
|
|
|
|
|
|
/* Clears given folder - keeps given number of files */
|
|
fun clearFolder(folder: File?, keep: Int, deleteFolder: Boolean = false) {
|
|
if (folder != null && folder.exists()) {
|
|
val files = folder.listFiles()!!
|
|
val fileCount: Int = files.size
|
|
files.sortBy { it.lastModified() }
|
|
for (fileNumber in files.indices) {
|
|
if (fileNumber < fileCount - keep) {
|
|
files[fileNumber].delete()
|
|
}
|
|
}
|
|
if (deleteFolder && keep == 0) {
|
|
folder.delete()
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/* Creates and save a scaled version of the station image */
|
|
fun saveStationImage(
|
|
context: Context,
|
|
stationUuid: String,
|
|
sourceImageUri: Uri,
|
|
size: Int,
|
|
fileName: String
|
|
): Uri {
|
|
val coverBitmap: Bitmap = ImageHelper.getScaledStationImage(context, sourceImageUri, size)
|
|
val file = File(
|
|
context.getExternalFilesDir(
|
|
determineDestinationFolderPath(
|
|
Keys.FILE_TYPE_IMAGE,
|
|
stationUuid
|
|
)
|
|
), fileName
|
|
)
|
|
writeImageFile(coverBitmap, file)
|
|
return file.toUri()
|
|
}
|
|
|
|
|
|
/* Saves collection of radio stations as JSON text file */
|
|
fun saveCollection(context: Context, collection: Collection, lastSave: Date) {
|
|
Log.v(TAG, "Saving collection - Thread: ${Thread.currentThread().name}")
|
|
val collectionSize: Int = collection.stations.size
|
|
// do not override an existing collection with an empty one - except when last station is deleted
|
|
if (collectionSize > 0 || PreferencesHelper.loadCollectionSize() == 1) {
|
|
// convert to JSON
|
|
val gson: Gson = getCustomGson()
|
|
var json = String()
|
|
try {
|
|
json = gson.toJson(collection)
|
|
} catch (e: Exception) {
|
|
e.printStackTrace()
|
|
}
|
|
if (json.isNotBlank()) {
|
|
// write text file
|
|
writeTextFile(context, json, Keys.FOLDER_COLLECTION, Keys.COLLECTION_FILE)
|
|
// save modification date and collection size
|
|
PreferencesHelper.saveCollectionModificationDate(lastSave)
|
|
PreferencesHelper.saveCollectionSize(collectionSize)
|
|
} else {
|
|
Log.w(TAG, "Not writing collection file. Reason: JSON string was completely empty.")
|
|
}
|
|
} else {
|
|
Log.w(
|
|
TAG,
|
|
"Not saving collection. Reason: Trying to override an collection with more than one station"
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
/* Reads m3u or pls playlists */
|
|
fun readStationPlaylist(playlistInputStream: InputStream?): Station {
|
|
val station = Station()
|
|
if (playlistInputStream != null) {
|
|
val reader = BufferedReader(InputStreamReader(playlistInputStream))
|
|
// until last line reached: read station name and stream address(es)
|
|
reader.forEachLine { line ->
|
|
when {
|
|
// M3U: found station name
|
|
line.contains("#EXTINF:-1,") -> station.name = line.substring(11).trim()
|
|
line.contains("#EXTINF:0,") -> station.name = line.substring(10).trim()
|
|
// M3U: found stream URL
|
|
line.startsWith("http") -> station.streamUris.add(0, line.trim())
|
|
// PLS: found station name
|
|
line.matches(Regex("^Title[0-9]+=.*")) -> station.name =
|
|
line.substring(line.indexOf("=") + 1).trim()
|
|
// PLS: found stream URL
|
|
line.matches(Regex("^File[0-9]+=http.*")) -> station.streamUris.add(
|
|
line.substring(
|
|
line.indexOf("=") + 1
|
|
).trim()
|
|
)
|
|
}
|
|
|
|
}
|
|
playlistInputStream.close()
|
|
}
|
|
return station
|
|
}
|
|
|
|
|
|
/* Reads collection of radio stations from storage using GSON */
|
|
fun readCollection(context: Context): Collection {
|
|
Log.v(TAG, "Reading collection - Thread: ${Thread.currentThread().name}")
|
|
// get JSON from text file
|
|
val json: String = readTextFileFromFile(context)
|
|
var collection = Collection()
|
|
if (json.isNotBlank()) {
|
|
// convert JSON and return as collection
|
|
try {
|
|
collection = getCustomGson().fromJson(json, collection::class.java)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Error Reading collection.\nContent: $json")
|
|
e.printStackTrace()
|
|
}
|
|
}
|
|
return collection
|
|
}
|
|
|
|
|
|
/* Get content Uri for M3U file */
|
|
fun getM3ulUri(activity: Activity): Uri? {
|
|
var m3ulUri: Uri? = null
|
|
// try to get an existing M3U File
|
|
var m3uFile =
|
|
File(activity.getExternalFilesDir(Keys.FOLDER_COLLECTION), Keys.COLLECTION_M3U_FILE)
|
|
if (!m3uFile.exists()) {
|
|
m3uFile = File(
|
|
activity.getExternalFilesDir(Keys.URLRADIO_LEGACY_FOLDER_COLLECTION),
|
|
Keys.COLLECTION_M3U_FILE
|
|
)
|
|
}
|
|
// get Uri for existing M3U File
|
|
if (m3uFile.exists()) {
|
|
m3ulUri = FileProvider.getUriForFile(
|
|
activity,
|
|
"${activity.applicationContext.packageName}.provider",
|
|
m3uFile
|
|
)
|
|
}
|
|
return m3ulUri
|
|
}
|
|
|
|
|
|
/* Get content Uri for PLS file */
|
|
fun getPlslUri(activity: Activity): Uri? {
|
|
var plslUri: Uri? = null
|
|
// try to get an existing PLS File
|
|
var plsFile =
|
|
File(activity.getExternalFilesDir(Keys.FOLDER_COLLECTION), Keys.COLLECTION_PLS_FILE)
|
|
if (!plsFile.exists()) {
|
|
plsFile = File(
|
|
activity.getExternalFilesDir(Keys.URLRADIO_LEGACY_FOLDER_COLLECTION),
|
|
Keys.COLLECTION_PLS_FILE
|
|
)
|
|
}
|
|
// get Uri for existing M3U File
|
|
if (plsFile.exists()) {
|
|
plslUri = FileProvider.getUriForFile(
|
|
activity,
|
|
"${activity.applicationContext.packageName}.provider",
|
|
plsFile
|
|
)
|
|
}
|
|
return plslUri
|
|
}
|
|
|
|
|
|
/* Suspend function: Wrapper for saveCollection */
|
|
suspend fun saveCollectionSuspended(
|
|
context: Context,
|
|
collection: Collection,
|
|
lastUpdate: Date
|
|
) {
|
|
return suspendCoroutine { cont ->
|
|
cont.resume(saveCollection(context, collection, lastUpdate))
|
|
}
|
|
}
|
|
|
|
|
|
/* Suspend function: Wrapper for readCollection */
|
|
suspend fun readCollectionSuspended(context: Context): Collection =
|
|
withContext(IO) {
|
|
readCollection(context)
|
|
}
|
|
|
|
|
|
/* Suspend function: Wrapper for copyFile */
|
|
suspend fun saveCopyOfFileSuspended(
|
|
context: Context,
|
|
originalFileUri: Uri,
|
|
targetFileUri: Uri
|
|
): Boolean {
|
|
return suspendCoroutine { cont ->
|
|
cont.resume(copyFile(context, originalFileUri, targetFileUri))
|
|
}
|
|
}
|
|
|
|
|
|
/* Suspend function: Exports collection of stations as M3U file - local backup copy */
|
|
suspend fun backupCollectionAsM3uSuspended(context: Context, collection: Collection) {
|
|
return suspendCoroutine { cont ->
|
|
Log.v(TAG, "Backing up collection as M3U - Thread: ${Thread.currentThread().name}")
|
|
// create M3U string
|
|
val m3uString: String = CollectionHelper.createM3uString(collection)
|
|
// save M3U as text file
|
|
cont.resume(
|
|
writeTextFile(
|
|
context,
|
|
m3uString,
|
|
Keys.FOLDER_COLLECTION,
|
|
Keys.COLLECTION_M3U_FILE
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
/* Suspend function: Exports collection of stations as PLS file - local backup copy */
|
|
suspend fun backupCollectionAsPlsSuspended(context: Context, collection: Collection) {
|
|
return suspendCoroutine { cont ->
|
|
Log.v(TAG, "Backing up collection as PLS - Thread: ${Thread.currentThread().name}")
|
|
// create PLS string
|
|
val plsString: String = CollectionHelper.createPlsString(collection)
|
|
// save PLS as text file
|
|
cont.resume(
|
|
writeTextFile(
|
|
context,
|
|
plsString,
|
|
Keys.FOLDER_COLLECTION,
|
|
Keys.COLLECTION_PLS_FILE
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
/* Copies file to specified target */
|
|
private fun copyFile(
|
|
context: Context,
|
|
originalFileUri: Uri,
|
|
targetFileUri: Uri,
|
|
): Boolean {
|
|
var success = true
|
|
var inputStream: InputStream? = null
|
|
val outputStream: OutputStream?
|
|
try {
|
|
inputStream = context.contentResolver.openInputStream(originalFileUri)
|
|
outputStream = context.contentResolver.openOutputStream(targetFileUri)
|
|
if (outputStream != null && inputStream != null) {
|
|
inputStream.copyTo(outputStream)
|
|
outputStream.close() // Close the output stream after copying
|
|
}
|
|
} catch (exception: Exception) {
|
|
Log.e(TAG, "Unable to copy file.")
|
|
success = false
|
|
exception.printStackTrace()
|
|
} finally {
|
|
inputStream?.close() // Close the input stream in the finally block
|
|
}
|
|
if (success) {
|
|
try {
|
|
// use contentResolver to handle files of type content://
|
|
context.contentResolver.delete(originalFileUri, null, null)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Unable to delete the original file. Stack trace: $e")
|
|
}
|
|
}
|
|
return success
|
|
}
|
|
|
|
|
|
/* Creates a Gson object */
|
|
private fun getCustomGson(): Gson {
|
|
val gsonBuilder = GsonBuilder()
|
|
gsonBuilder.setDateFormat("M/d/yy hh:mm a")
|
|
gsonBuilder.excludeFieldsWithoutExposeAnnotation()
|
|
return gsonBuilder.create()
|
|
}
|
|
|
|
|
|
/* Create nomedia file in given folder to prevent media scanning */
|
|
fun createNomediaFile(folder: File?) {
|
|
if (folder != null && folder.exists() && folder.isDirectory) {
|
|
val nomediaFile: File = getNoMediaFile(folder)
|
|
if (!nomediaFile.exists()) {
|
|
val noMediaOutStream = FileOutputStream(getNoMediaFile(folder))
|
|
noMediaOutStream.write(0)
|
|
} else {
|
|
Log.v(TAG, ".nomedia file exists already in given folder.")
|
|
}
|
|
} else {
|
|
Log.w(TAG, "Unable to create .nomedia file. Given folder is not valid.")
|
|
}
|
|
}
|
|
|
|
|
|
/* Reads InputStream from file uri and returns it as String */
|
|
private fun readTextFileFromFile(context: Context): String {
|
|
// check if file exists
|
|
val file = File(context.getExternalFilesDir(Keys.FOLDER_COLLECTION), Keys.COLLECTION_FILE)
|
|
if (!file.exists() || !file.canRead()) {
|
|
return String()
|
|
}
|
|
// read until last line reached
|
|
val uri = Uri.fromFile(file)
|
|
val stream: InputStream = context.contentResolver.openInputStream(uri) ?: return String()
|
|
val reader = BufferedReader(InputStreamReader(stream))
|
|
val builder: StringBuilder = StringBuilder()
|
|
reader.forEachLine {
|
|
builder.append(it)
|
|
builder.append("\n")
|
|
}
|
|
stream.close()
|
|
return builder.toString()
|
|
}
|
|
|
|
|
|
/* Reads InputStream from content uri and returns it as List of String */
|
|
fun readTextFileFromContentUri(context: Context, contentUri: Uri): List<String> {
|
|
val lines: MutableList<String> = mutableListOf()
|
|
try {
|
|
// open input stream from content URI
|
|
val inputStream: InputStream? = context.contentResolver.openInputStream(contentUri)
|
|
if (inputStream != null) {
|
|
val reader: InputStreamReader = inputStream.reader()
|
|
var index = 0
|
|
reader.forEachLine {
|
|
index += 1
|
|
if (index < 256)
|
|
lines.add(it)
|
|
}
|
|
inputStream.close()
|
|
}
|
|
} catch (e: Exception) {
|
|
e.printStackTrace()
|
|
}
|
|
return lines
|
|
}
|
|
|
|
|
|
/* Writes given text to file on storage */
|
|
@Suppress("SameParameterValue")
|
|
private fun writeTextFile(context: Context, text: String, folder: String, fileName: String) {
|
|
if (text.isNotBlank()) {
|
|
File(context.getExternalFilesDir(folder), fileName).writeText(text)
|
|
} else {
|
|
Log.w(TAG, "Writing text file $fileName failed. Empty text string text was provided.")
|
|
}
|
|
}
|
|
|
|
|
|
/* Writes given bitmap as image file to storage */
|
|
private fun writeImageFile(
|
|
bitmap: Bitmap,
|
|
file: File
|
|
) {
|
|
if (file.exists()) file.delete()
|
|
try {
|
|
val out = FileOutputStream(file)
|
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 75, out)
|
|
out.flush()
|
|
out.close()
|
|
} catch (e: Exception) {
|
|
e.printStackTrace()
|
|
}
|
|
}
|
|
|
|
|
|
/* Returns a nomedia file object */
|
|
private fun getNoMediaFile(folder: File): File {
|
|
return File(folder, ".nomedia")
|
|
}
|
|
|
|
}
|