mirror of
https://github.com/Michatec/Radio.git
synced 2026-04-01 16:06:27 +02:00
Initial commit
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* AppThemeHelper.kt
|
||||
* Implements the AppThemeHelper object
|
||||
* A AppThemeHelper can set the different app themes: Light Mode, Dark Mode, Follow System
|
||||
*
|
||||
* 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.content.Context
|
||||
import android.content.res.TypedArray
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.R
|
||||
|
||||
|
||||
/*
|
||||
* AppThemeHelper object
|
||||
*/
|
||||
object AppThemeHelper {
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = AppThemeHelper::class.java.simpleName
|
||||
|
||||
private val sTypedValue = TypedValue()
|
||||
|
||||
/* Sets app theme */
|
||||
fun setTheme(nightModeState: String) {
|
||||
when (nightModeState) {
|
||||
Keys.STATE_THEME_DARK_MODE -> {
|
||||
if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_YES) {
|
||||
// turn on dark mode
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
Log.i(TAG, "Dark Mode activated.")
|
||||
}
|
||||
}
|
||||
Keys.STATE_THEME_LIGHT_MODE -> {
|
||||
if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_NO) {
|
||||
// turn on light mode
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
Log.i(TAG, "Theme: Light Mode activated.")
|
||||
}
|
||||
}
|
||||
Keys.STATE_THEME_FOLLOW_SYSTEM -> {
|
||||
if (AppCompatDelegate.getDefaultNightMode() != AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) {
|
||||
// turn on mode "follow system"
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
Log.i(TAG, "Theme: Follow System Mode activated.")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// turn on mode "follow system"
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
Log.i(TAG, "Theme: Follow System Mode activated.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Returns a readable String for currently selected App Theme */
|
||||
fun getCurrentTheme(context: Context): String {
|
||||
return when (PreferencesHelper.loadThemeSelection()) {
|
||||
Keys.STATE_THEME_LIGHT_MODE -> context.getString(R.string.pref_theme_selection_mode_light)
|
||||
Keys.STATE_THEME_DARK_MODE -> context.getString(R.string.pref_theme_selection_mode_dark)
|
||||
else -> context.getString(R.string.pref_theme_selection_mode_device_default)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ColorInt
|
||||
fun getColor(context: Context, @AttrRes resource: Int): Int {
|
||||
val a: TypedArray = context.obtainStyledAttributes(sTypedValue.data, intArrayOf(resource))
|
||||
val color = a.getColor(0, 0)
|
||||
a.recycle()
|
||||
return color
|
||||
}
|
||||
|
||||
}
|
||||
64
app/src/main/java/com/michatec/radio/helpers/AudioHelper.kt
Normal file
64
app/src/main/java/com/michatec/radio/helpers/AudioHelper.kt
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* AudioHelper.kt
|
||||
* Implements the AudioHelper object
|
||||
* A AudioHelper provides helper methods for handling audio files
|
||||
*
|
||||
* 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.util.Log
|
||||
import androidx.media3.common.Metadata
|
||||
import androidx.media3.extractor.metadata.icy.IcyHeaders
|
||||
import androidx.media3.extractor.metadata.icy.IcyInfo
|
||||
import com.michatec.radio.Keys
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
/*
|
||||
* AudioHelper object
|
||||
*/
|
||||
object AudioHelper {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = AudioHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* Extract audio stream metadata */
|
||||
fun getMetadataString(metadata: Metadata): String {
|
||||
var metadataString = String()
|
||||
for (i in 0 until metadata.length()) {
|
||||
// extract IceCast metadata
|
||||
when (val entry = metadata.get(i)) {
|
||||
is IcyInfo -> {
|
||||
metadataString = entry.title.toString()
|
||||
}
|
||||
|
||||
is IcyHeaders -> {
|
||||
Log.i(TAG, "icyHeaders:" + entry.name + " - " + entry.genre)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Unsupported metadata received (type = ${entry.javaClass.simpleName})")
|
||||
}
|
||||
}
|
||||
// TODO implement HLS metadata extraction (Id3Frame / PrivFrame)
|
||||
// https://exoplayer.dev/doc/reference/com/google/android/exoplayer2/metadata/Metadata.Entry.html
|
||||
}
|
||||
// ensure a max length of the metadata string
|
||||
if (metadataString.isNotEmpty()) {
|
||||
metadataString = metadataString.substring(0, min(metadataString.length, Keys.DEFAULT_MAX_LENGTH_OF_METADATA_ENTRY))
|
||||
}
|
||||
return metadataString
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
179
app/src/main/java/com/michatec/radio/helpers/BackupHelper.kt
Normal file
179
app/src/main/java/com/michatec/radio/helpers/BackupHelper.kt
Normal file
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* BackupHelper.kt
|
||||
* Implements the BackupHelper object
|
||||
* A BackupHelper provides helper methods for backing up and restoring the radio station collection
|
||||
*
|
||||
* 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.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.michatec.radio.R
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
object BackupHelper {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = BackupHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* Compresses all files in the app's external files directory into destination zip file */
|
||||
fun backup(view: View, context: Context, destinationUri: Uri) {
|
||||
val sourceFolder: File? = context.getExternalFilesDir("")
|
||||
if (sourceFolder != null && sourceFolder.isDirectory) {
|
||||
Snackbar.make(
|
||||
view,
|
||||
"${
|
||||
FileHelper.getFileName(
|
||||
context,
|
||||
destinationUri
|
||||
)
|
||||
} ${context.getString(R.string.toastmessage_backed_up)}",
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
val resolver: ContentResolver = context.contentResolver
|
||||
val outputStream: OutputStream? = resolver.openOutputStream(destinationUri)
|
||||
ZipOutputStream(BufferedOutputStream(outputStream)).use { zipOutputStream ->
|
||||
zipOutputStream.use {
|
||||
zipFolder(it, sourceFolder, "")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Unable to access External Storage.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Extracts zip backup file and restores files and folders - Credit: https://www.baeldung.com/java-compress-and-uncompress*/
|
||||
fun restore(view: View, context: Context, sourceUri: Uri) {
|
||||
Snackbar.make(view, R.string.toastmessage_restored, Snackbar.LENGTH_LONG).show()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// bypass "ZipException" for Android 14 or above applications when zip file names contain ".." or start with "/"
|
||||
dalvik.system.ZipPathValidator.clearCallback()
|
||||
}
|
||||
|
||||
val resolver: ContentResolver = context.contentResolver
|
||||
val sourceInputStream: InputStream? = resolver.openInputStream(sourceUri)
|
||||
val destinationFolder: File? = context.getExternalFilesDir("")
|
||||
val buffer = ByteArray(1024)
|
||||
val zipInputStream = ZipInputStream(sourceInputStream)
|
||||
var zipEntry: ZipEntry? = zipInputStream.nextEntry
|
||||
|
||||
// iterate through ZipInputStream until last ZipEntry
|
||||
while (zipEntry != null) {
|
||||
try {
|
||||
val newFile: File = getFile(destinationFolder!!, zipEntry)
|
||||
when (zipEntry.isDirectory) {
|
||||
// CASE: Folder
|
||||
true -> {
|
||||
// create folder if zip entry is a folder
|
||||
if (!newFile.isDirectory && !newFile.mkdirs()) {
|
||||
Log.w(TAG, "Failed to create directory $newFile")
|
||||
}
|
||||
}
|
||||
// CASE: File
|
||||
false -> {
|
||||
// create parent directory, if necessary
|
||||
val parent: File? = newFile.parentFile
|
||||
if (parent != null && !parent.isDirectory && !parent.mkdirs()) {
|
||||
Log.w(TAG, "Failed to create directory $parent")
|
||||
}
|
||||
// write file content
|
||||
val fileOutputStream = FileOutputStream(newFile)
|
||||
var len: Int
|
||||
while (zipInputStream.read(buffer).also { len = it } > 0) {
|
||||
fileOutputStream.write(buffer, 0, len)
|
||||
}
|
||||
fileOutputStream.close()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to safely create file. $e")
|
||||
}
|
||||
// get next entry - zipEntry will be null, when zipInputStream has no more entries left
|
||||
zipEntry = zipInputStream.nextEntry
|
||||
}
|
||||
zipInputStream.closeEntry()
|
||||
zipInputStream.close()
|
||||
|
||||
// notify CollectionViewModel that collection has changed
|
||||
CollectionHelper.sendCollectionBroadcast(
|
||||
context,
|
||||
modificationDate = Calendar.getInstance().time
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/* Compresses folder into ZIP file - Credit: https://stackoverflow.com/a/52216574 */
|
||||
private fun zipFolder(zipOutputStream: ZipOutputStream, source: File, parentDirPath: String) {
|
||||
// source.listFiles() will return null, if source is not a directory
|
||||
if (source.isDirectory) {
|
||||
val data = ByteArray(2048)
|
||||
// get all File objects in folder
|
||||
for (file in source.listFiles()!!) {
|
||||
// make sure that path does not start with a separator (/)
|
||||
val path: String = if (parentDirPath.isEmpty()) file.name else parentDirPath + File.separator + file.name
|
||||
when (file.isDirectory) {
|
||||
// CASE: Folder
|
||||
true -> {
|
||||
// call zipFolder recursively to add files within this folder
|
||||
zipFolder(zipOutputStream, file, path)
|
||||
}
|
||||
// CASE: File
|
||||
false -> {
|
||||
FileInputStream(file).use { fileInputStream ->
|
||||
BufferedInputStream(fileInputStream).use { bufferedInputStream ->
|
||||
val entry = ZipEntry(path)
|
||||
entry.time = file.lastModified()
|
||||
entry.size = file.length()
|
||||
zipOutputStream.putNextEntry(entry)
|
||||
while (true) {
|
||||
val readBytes = bufferedInputStream.read(data)
|
||||
if (readBytes == -1) {
|
||||
break
|
||||
}
|
||||
zipOutputStream.write(data, 0, readBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Normalize file path - protects against zip slip attack */
|
||||
@Throws(IOException::class)
|
||||
private fun getFile(destinationFolder: File, zipEntry: ZipEntry): File {
|
||||
val destinationFile = File(destinationFolder, zipEntry.name)
|
||||
val destinationFolderPath = destinationFolder.canonicalPath
|
||||
val destinationFilePath = destinationFile.canonicalPath
|
||||
// make sure that zipEntry path is in the destination folder
|
||||
if (!destinationFilePath.startsWith(destinationFolderPath + File.separator)) {
|
||||
throw IOException("ZIP entry is not within of the destination folder: " + zipEntry.name)
|
||||
}
|
||||
return destinationFile
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
773
app/src/main/java/com/michatec/radio/helpers/CollectionHelper.kt
Normal file
773
app/src/main/java/com/michatec/radio/helpers/CollectionHelper.kt
Normal file
@@ -0,0 +1,773 @@
|
||||
/*
|
||||
* CollectionHelper.kt
|
||||
* Implements the CollectionHelper object
|
||||
* A CollectionHelper provides helper methods for the collection of stations
|
||||
*
|
||||
* 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.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.R
|
||||
import com.michatec.radio.core.Collection
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.search.DirectInputCheck
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* CollectionHelper object
|
||||
*/
|
||||
object CollectionHelper {
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = CollectionHelper::class.java.simpleName
|
||||
|
||||
/* Checks if station is already in collection */
|
||||
private fun isNewStation(collection: Collection, station: Station): Boolean {
|
||||
collection.stations.forEach {
|
||||
if (it.getStreamUri() == station.getStreamUri()) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/* Checks if station is already in collection */
|
||||
fun isNewStation(collection: Collection, remoteStationLocation: String): Boolean {
|
||||
collection.stations.forEach {
|
||||
if (it.remoteStationLocation == remoteStationLocation) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/* Checks if a newer collection of radio stations is available on storage */
|
||||
fun isNewerCollectionAvailable(date: Date): Boolean {
|
||||
var newerCollectionAvailable = false
|
||||
val modificationDate: Date = PreferencesHelper.loadCollectionModificationDate()
|
||||
if (modificationDate.after(date) || date == Keys.DEFAULT_DATE) {
|
||||
newerCollectionAvailable = true
|
||||
}
|
||||
return newerCollectionAvailable
|
||||
}
|
||||
|
||||
|
||||
/* Creates station from previously downloaded playlist file */
|
||||
fun createStationFromPlaylistFile(
|
||||
context: Context,
|
||||
localFileUri: Uri,
|
||||
remoteFileLocation: String
|
||||
): Station {
|
||||
// read station playlist
|
||||
val station: Station =
|
||||
FileHelper.readStationPlaylist(context.contentResolver.openInputStream(localFileUri))
|
||||
if (station.name.isEmpty()) {
|
||||
// construct name from file name - strips file extension
|
||||
station.name = FileHelper.getFileName(context, localFileUri).substringBeforeLast(".")
|
||||
}
|
||||
station.remoteStationLocation = remoteFileLocation
|
||||
station.remoteImageLocation = getFaviconAddress(remoteFileLocation)
|
||||
station.modificationDate = GregorianCalendar.getInstance().time
|
||||
return station
|
||||
}
|
||||
|
||||
|
||||
/* Updates radio station in collection */
|
||||
fun updateStation(context: Context, collection: Collection, station: Station): Collection {
|
||||
var updatedCollection: Collection = collection
|
||||
|
||||
// CASE: Update station retrieved from radio browser
|
||||
if (station.radioBrowserStationUuid.isNotEmpty()) {
|
||||
updatedCollection.stations.forEach {
|
||||
if (it.radioBrowserStationUuid == station.radioBrowserStationUuid) {
|
||||
// update station in collection with values from new station
|
||||
it.streamUris[it.stream] = station.getStreamUri()
|
||||
it.streamContent = station.streamContent
|
||||
it.remoteImageLocation = station.remoteImageLocation
|
||||
it.remoteStationLocation = station.remoteStationLocation
|
||||
it.homepage = station.homepage
|
||||
// update name - if not changed previously by user
|
||||
if (!it.nameManuallySet) it.name = station.name
|
||||
// re-download station image - if new URL and not changed previously by user
|
||||
DownloadHelper.updateStationImage(context, it)
|
||||
}
|
||||
}
|
||||
// sort and save collection
|
||||
updatedCollection = sortCollection(updatedCollection)
|
||||
saveCollection(context, updatedCollection, false)
|
||||
}
|
||||
|
||||
// CASE: Update station retrieved via playlist
|
||||
else if (station.remoteStationLocation.isNotEmpty()) {
|
||||
updatedCollection.stations.forEach {
|
||||
if (it.remoteStationLocation == station.remoteStationLocation) {
|
||||
// update stream uri, mime type and station image url
|
||||
it.streamUris[it.stream] = station.getStreamUri()
|
||||
it.streamContent = station.streamContent
|
||||
it.remoteImageLocation = station.remoteImageLocation
|
||||
// update name - if not changed previously by user
|
||||
if (!it.nameManuallySet) it.name = station.name
|
||||
// re-download station image - if not changed previously by user
|
||||
if (!it.imageManuallySet) DownloadHelper.updateStationImage(context, it)
|
||||
}
|
||||
}
|
||||
// sort and save collection
|
||||
updatedCollection = sortCollection(updatedCollection)
|
||||
saveCollection(context, updatedCollection, false)
|
||||
}
|
||||
|
||||
return updatedCollection
|
||||
}
|
||||
|
||||
|
||||
/* Adds new radio station to collection */
|
||||
fun addStation(context: Context, collection: Collection, newStation: Station): Collection {
|
||||
// check validity
|
||||
if (!newStation.isValid()) {
|
||||
Toast.makeText(context, R.string.toastmessage_station_not_valid, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
return collection
|
||||
}
|
||||
// duplicate check
|
||||
else if (!isNewStation(collection, newStation)) {
|
||||
// update station
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Toast.makeText(context, R.string.toastmessage_station_duplicate, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
return collection
|
||||
}
|
||||
// all clear -> add station
|
||||
else {
|
||||
var updatedCollection: Collection = collection
|
||||
val updatedStationList: MutableList<Station> = collection.stations.toMutableList()
|
||||
// add station
|
||||
updatedStationList.add(newStation)
|
||||
updatedCollection.stations = updatedStationList
|
||||
// sort and save collection
|
||||
updatedCollection = sortCollection(updatedCollection)
|
||||
saveCollection(context, updatedCollection, false)
|
||||
// download station image
|
||||
DownloadHelper.updateStationImage(context, newStation)
|
||||
// return updated collection
|
||||
return updatedCollection
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Sets station image - determines station by remote image file location */
|
||||
fun setStationImageWithRemoteLocation(
|
||||
context: Context,
|
||||
collection: Collection,
|
||||
tempImageFileUri: String,
|
||||
remoteFileLocation: String,
|
||||
imageManuallySet: Boolean = false
|
||||
): Collection {
|
||||
collection.stations.forEach { station ->
|
||||
// compare image location protocol-agnostic (= without http / https)
|
||||
if (station.remoteImageLocation.substringAfter(":") == remoteFileLocation.substringAfter(
|
||||
":"
|
||||
)
|
||||
) {
|
||||
station.smallImage = FileHelper.saveStationImage(
|
||||
context,
|
||||
station.uuid,
|
||||
tempImageFileUri.toUri(),
|
||||
Keys.SIZE_STATION_IMAGE_CARD,
|
||||
Keys.STATION_IMAGE_FILE
|
||||
).toString()
|
||||
station.image = FileHelper.saveStationImage(
|
||||
context,
|
||||
station.uuid,
|
||||
tempImageFileUri.toUri(),
|
||||
Keys.SIZE_STATION_IMAGE_MAXIMUM,
|
||||
Keys.STATION_IMAGE_FILE
|
||||
).toString()
|
||||
station.imageColor = ImageHelper.getMainColor(context, tempImageFileUri.toUri())
|
||||
station.imageManuallySet = imageManuallySet
|
||||
}
|
||||
}
|
||||
// save and return collection
|
||||
saveCollection(context, collection)
|
||||
return collection
|
||||
}
|
||||
|
||||
|
||||
/* Sets station image - determines station by remote image file location */
|
||||
fun setStationImageWithStationUuid(
|
||||
context: Context,
|
||||
collection: Collection,
|
||||
tempImageFileUri: Uri,
|
||||
stationUuid: String,
|
||||
imageManuallySet: Boolean = false
|
||||
): Collection {
|
||||
collection.stations.forEach { station ->
|
||||
// find station by uuid
|
||||
if (station.uuid == stationUuid) {
|
||||
station.smallImage = FileHelper.saveStationImage(
|
||||
context,
|
||||
station.uuid,
|
||||
tempImageFileUri,
|
||||
Keys.SIZE_STATION_IMAGE_CARD,
|
||||
Keys.STATION_IMAGE_FILE
|
||||
).toString()
|
||||
station.image = FileHelper.saveStationImage(
|
||||
context,
|
||||
station.uuid,
|
||||
tempImageFileUri,
|
||||
Keys.SIZE_STATION_IMAGE_MAXIMUM,
|
||||
Keys.STATION_IMAGE_FILE
|
||||
).toString()
|
||||
station.imageColor = ImageHelper.getMainColor(context, tempImageFileUri)
|
||||
station.imageManuallySet = imageManuallySet
|
||||
}
|
||||
}
|
||||
// save and return collection
|
||||
saveCollection(context, collection)
|
||||
return collection
|
||||
}
|
||||
|
||||
|
||||
/* Clears an image folder for a given station */
|
||||
fun clearImagesFolder(context: Context, station: Station) {
|
||||
// clear image folder
|
||||
val imagesFolder = File(
|
||||
context.getExternalFilesDir(""),
|
||||
FileHelper.determineDestinationFolderPath(Keys.FILE_TYPE_IMAGE, station.uuid)
|
||||
)
|
||||
FileHelper.clearFolder(imagesFolder, 0)
|
||||
}
|
||||
|
||||
|
||||
/* Deletes Images of a given station */
|
||||
fun deleteStationImages(context: Context, station: Station) {
|
||||
val imagesFolder = File(
|
||||
context.getExternalFilesDir(""),
|
||||
FileHelper.determineDestinationFolderPath(Keys.FILE_TYPE_IMAGE, station.uuid)
|
||||
)
|
||||
FileHelper.clearFolder(imagesFolder, 0, true)
|
||||
}
|
||||
|
||||
|
||||
/* Get station from collection for given UUID */
|
||||
fun getStation(collection: Collection, stationUuid: String): Station {
|
||||
collection.stations.forEach { station ->
|
||||
if (station.uuid == stationUuid) {
|
||||
return station
|
||||
}
|
||||
}
|
||||
// fallback: return first station
|
||||
return if (collection.stations.isNotEmpty()) {
|
||||
collection.stations.first()
|
||||
} else {
|
||||
Station()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Gets MediaIem for next station within collection */
|
||||
fun getNextMediaItem(context: Context, collection: Collection, stationUuid: String): MediaItem {
|
||||
val currentStationPosition: Int = getStationPosition(collection, stationUuid)
|
||||
return if (collection.stations.isEmpty() || currentStationPosition == -1) {
|
||||
buildMediaItem(context, Station())
|
||||
} else if (currentStationPosition < collection.stations.size -1) {
|
||||
buildMediaItem(context, collection.stations[currentStationPosition + 1])
|
||||
} else {
|
||||
buildMediaItem(context, collection.stations.first())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Gets MediaIem for previous station within collection */
|
||||
fun getPreviousMediaItem(context: Context, collection: Collection, stationUuid: String): MediaItem {
|
||||
val currentStationPosition: Int = getStationPosition(collection, stationUuid)
|
||||
return if (collection.stations.isEmpty() || currentStationPosition == -1) {
|
||||
buildMediaItem(context, Station())
|
||||
} else if (currentStationPosition > 0) {
|
||||
buildMediaItem(context, collection.stations[currentStationPosition - 1])
|
||||
} else {
|
||||
buildMediaItem(context, collection.stations.last())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Get the position from collection for given UUID */
|
||||
fun getStationPosition(collection: Collection, stationUuid: String): Int {
|
||||
collection.stations.forEachIndexed { stationId, station ->
|
||||
if (station.uuid == stationUuid) {
|
||||
return stationId
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
|
||||
/* Get the position from collection for given radioBrowserStationUuid */
|
||||
fun getStationPositionFromRadioBrowserStationUuid(
|
||||
collection: Collection,
|
||||
radioBrowserStationUuid: String
|
||||
): Int {
|
||||
collection.stations.forEachIndexed { stationId, station ->
|
||||
if (station.radioBrowserStationUuid == radioBrowserStationUuid) {
|
||||
return stationId
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
|
||||
/* Returns the children stations under under root (simple media library structure: root > stations) */
|
||||
fun getChildren(context: Context, collection: Collection): List<MediaItem> {
|
||||
val mediaItems: MutableList<MediaItem> = mutableListOf()
|
||||
collection.stations.forEach { station ->
|
||||
mediaItems.add(buildMediaItem(context, station))
|
||||
}
|
||||
return mediaItems
|
||||
}
|
||||
|
||||
|
||||
/* Returns media item for given station id */
|
||||
fun getItem(context: Context, collection: Collection, stationUuid: String): MediaItem {
|
||||
return buildMediaItem(context, getStation(collection, stationUuid))
|
||||
}
|
||||
|
||||
|
||||
/* Returns media item for last played station */
|
||||
fun getRecent(context: Context, collection: Collection): MediaItem {
|
||||
return buildMediaItem(context, getStation(collection, PreferencesHelper.loadLastPlayedStationUuid()))
|
||||
}
|
||||
|
||||
|
||||
/* Returns the library root item */
|
||||
fun getRootItem(): MediaItem {
|
||||
val metadata: MediaMetadata = MediaMetadata.Builder()
|
||||
.setTitle("Root Folder")
|
||||
.setIsPlayable(false)
|
||||
.setIsBrowsable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
|
||||
.build()
|
||||
return MediaItem.Builder()
|
||||
.setMediaId("[rootID]")
|
||||
.setMediaMetadata(metadata)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
/* Saves the playback state of a given station */
|
||||
fun savePlaybackState(
|
||||
context: Context,
|
||||
collection: Collection,
|
||||
stationUuid: String,
|
||||
isPlaying: Boolean
|
||||
): Collection {
|
||||
collection.stations.forEach {
|
||||
// reset playback state everywhere
|
||||
it.isPlaying = false
|
||||
// set given playback state at this station
|
||||
if (it.uuid == stationUuid) {
|
||||
it.isPlaying = isPlaying
|
||||
}
|
||||
}
|
||||
// save collection and store modification date
|
||||
collection.modificationDate = saveCollection(context, collection)
|
||||
return collection
|
||||
}
|
||||
|
||||
|
||||
/* Saves collection of radio stations */
|
||||
fun saveCollection(context: Context, collection: Collection, async: Boolean = true): Date {
|
||||
Log.v(
|
||||
TAG,
|
||||
"Saving collection of radio stations to storage. Async = ${async}. Size = ${collection.stations.size}"
|
||||
)
|
||||
// get modification date
|
||||
val date: Date = Calendar.getInstance().time
|
||||
collection.modificationDate = date
|
||||
// save collection to storage
|
||||
when (async) {
|
||||
true -> {
|
||||
CoroutineScope(IO).launch {
|
||||
// save collection on background thread
|
||||
FileHelper.saveCollectionSuspended(context, collection, date)
|
||||
// broadcast collection update
|
||||
sendCollectionBroadcast(context, date)
|
||||
}
|
||||
}
|
||||
false -> {
|
||||
// save collection
|
||||
FileHelper.saveCollection(context, collection, date)
|
||||
// broadcast collection update
|
||||
sendCollectionBroadcast(context, date)
|
||||
}
|
||||
}
|
||||
// return modification date
|
||||
return date
|
||||
}
|
||||
|
||||
|
||||
/* Creates station from playlist URLs and stream address URLs */
|
||||
suspend fun createStationsFromUrl(query: String, lastCheckedAddress: String = String()): List<Station> {
|
||||
val stationList: MutableList<Station> = mutableListOf()
|
||||
val contentType: String = NetworkHelper.detectContentType(query).type.lowercase(Locale.getDefault())
|
||||
val directInputCheck: DirectInputCheck? = null
|
||||
|
||||
// CASE: M3U playlist detected
|
||||
if (Keys.MIME_TYPES_M3U.contains(contentType)) {
|
||||
val lines: List<String> = NetworkHelper.downloadPlaylist(query)
|
||||
stationList.addAll(readM3uPlaylistContent(lines))
|
||||
}
|
||||
// CASE: PLS playlist detected
|
||||
else if (Keys.MIME_TYPES_PLS.contains(contentType)) {
|
||||
val lines: List<String> = NetworkHelper.downloadPlaylist(query)
|
||||
stationList.addAll(readPlsPlaylistContent(lines))
|
||||
}
|
||||
// CASE: stream address detected
|
||||
else if (Keys.MIME_TYPES_MPEG.contains(contentType) or
|
||||
Keys.MIME_TYPES_OGG.contains(contentType) or
|
||||
Keys.MIME_TYPES_AAC.contains(contentType) or
|
||||
Keys.MIME_TYPES_HLS.contains(contentType)) {
|
||||
// process Icecast stream and extract metadata
|
||||
directInputCheck?.processIcecastStream(query, stationList)
|
||||
// create station and add to collection
|
||||
val station = Station(name = query, streamUris = mutableListOf(query), streamContent = contentType, modificationDate = GregorianCalendar.getInstance().time)
|
||||
if (lastCheckedAddress != query) {
|
||||
stationList.add(station)
|
||||
}
|
||||
}
|
||||
return stationList
|
||||
}
|
||||
|
||||
|
||||
/* Creates station from URI pointing to a local file */
|
||||
fun createStationListFromContentUri(context: Context, contentUri: Uri): List<Station> {
|
||||
val stationList: MutableList<Station> = mutableListOf()
|
||||
val fileType: String = FileHelper.getContentType(context, contentUri)
|
||||
// CASE: M3U playlist detected
|
||||
if (Keys.MIME_TYPES_M3U.contains(fileType)) {
|
||||
val playlist = FileHelper.readTextFileFromContentUri(context, contentUri)
|
||||
stationList.addAll(readM3uPlaylistContent(playlist))
|
||||
}
|
||||
// CASE: PLS playlist detected
|
||||
else if (Keys.MIME_TYPES_PLS.contains(fileType)) {
|
||||
val playlist = FileHelper.readTextFileFromContentUri(context, contentUri)
|
||||
stationList.addAll(readPlsPlaylistContent(playlist))
|
||||
}
|
||||
return stationList
|
||||
}
|
||||
|
||||
|
||||
/* Reads a m3u playlist and returns a list of stations */
|
||||
private fun readM3uPlaylistContent(playlist: List<String>): List<Station> {
|
||||
val stations: MutableList<Station> = mutableListOf()
|
||||
var name = String()
|
||||
var streamUri: String
|
||||
var contentType: String
|
||||
|
||||
playlist.forEach { line ->
|
||||
// get name of station
|
||||
if (line.startsWith("#EXTINF:")) {
|
||||
name = line.substringAfter(",").trim()
|
||||
}
|
||||
// get stream uri and check mime type
|
||||
else if (line.isNotBlank() && !line.startsWith("#")) {
|
||||
streamUri = line.trim()
|
||||
// use the stream address as the name if no name is specified
|
||||
if (name.isEmpty()) {
|
||||
name = streamUri
|
||||
}
|
||||
contentType = NetworkHelper.detectContentType(streamUri).type.lowercase(Locale.getDefault())
|
||||
// store station in list if mime type is supported
|
||||
if (contentType != Keys.MIME_TYPE_UNSUPPORTED) {
|
||||
val station = Station(name = name, streamUris = mutableListOf(streamUri), streamContent = contentType, modificationDate = GregorianCalendar.getInstance().time)
|
||||
stations.add(station)
|
||||
}
|
||||
// reset name for the next station - useful if playlist does not provide name(s)
|
||||
name = String()
|
||||
}
|
||||
}
|
||||
return stations
|
||||
}
|
||||
|
||||
|
||||
/* Reads a pls playlist and returns a list of stations */
|
||||
private fun readPlsPlaylistContent(playlist: List<String>): List<Station> {
|
||||
val stations: MutableList<Station> = mutableListOf()
|
||||
var name = String()
|
||||
var streamUri: String
|
||||
var contentType: String
|
||||
|
||||
playlist.forEachIndexed { index, line ->
|
||||
// get stream uri and check mime type
|
||||
if (line.startsWith("File")) {
|
||||
streamUri = line.substringAfter("=").trim()
|
||||
contentType = NetworkHelper.detectContentType(streamUri).type.lowercase(Locale.getDefault())
|
||||
if (contentType != Keys.MIME_TYPE_UNSUPPORTED) {
|
||||
// look for the matching station name
|
||||
val number: String = line.substring(4 /* File */, line.indexOf("="))
|
||||
val lineBeforeIndex: Int = index - 1
|
||||
val lineAfterIndex: Int = index + 1
|
||||
// first: check the line before
|
||||
if (lineBeforeIndex >= 0) {
|
||||
val lineBefore: String = playlist[lineBeforeIndex]
|
||||
if (lineBefore.startsWith("Title$number")) {
|
||||
name = lineBefore.substringAfter("=").trim()
|
||||
}
|
||||
}
|
||||
// then: check the line after
|
||||
if (name.isEmpty() && lineAfterIndex < playlist.size) {
|
||||
val lineAfter: String = playlist[lineAfterIndex]
|
||||
if (lineAfter.startsWith("Title$number")) {
|
||||
name = lineAfter.substringAfter("=").trim()
|
||||
}
|
||||
}
|
||||
// fallback: use stream uri as name
|
||||
if (name.isEmpty()) {
|
||||
name = streamUri
|
||||
}
|
||||
// add station
|
||||
val station = Station(name = name, streamUris = mutableListOf(streamUri), streamContent = contentType, modificationDate = GregorianCalendar.getInstance().time)
|
||||
stations.add(station)
|
||||
}
|
||||
}
|
||||
}
|
||||
return stations
|
||||
}
|
||||
|
||||
|
||||
/* Export collection of stations as M3U */
|
||||
fun exportCollectionM3u(context: Context, collection: Collection) {
|
||||
Log.v(TAG, "Exporting collection of stations as M3U")
|
||||
// export collection as M3U - launch = fire & forget (no return value from save collection)
|
||||
if (collection.stations.size > 0) {
|
||||
CoroutineScope(IO).launch {
|
||||
FileHelper.backupCollectionAsM3uSuspended(
|
||||
context,
|
||||
collection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Create M3U string from collection of stations */
|
||||
fun createM3uString(collection: Collection): String {
|
||||
val m3uString = StringBuilder()
|
||||
/* Extended M3U Format
|
||||
#EXTM3U
|
||||
#EXTINF:-1,My Cool Stream
|
||||
http://www.site.com:8000/listen.pls
|
||||
*/
|
||||
|
||||
// add opening tag
|
||||
m3uString.append("#EXTM3U")
|
||||
m3uString.append("\n")
|
||||
|
||||
// add name and stream address
|
||||
collection.stations.forEach { station ->
|
||||
m3uString.append("\n")
|
||||
m3uString.append("#EXTINF:-1,")
|
||||
m3uString.append(station.name)
|
||||
m3uString.append("\n")
|
||||
m3uString.append(station.getStreamUri())
|
||||
m3uString.append("\n")
|
||||
}
|
||||
|
||||
return m3uString.toString()
|
||||
}
|
||||
|
||||
|
||||
/* Export collection of stations as PLS */
|
||||
fun exportCollectionPls(context: Context, collection: Collection) {
|
||||
Log.v(TAG, "Exporting collection of stations as PLS")
|
||||
// export collection as PLS - launch = fire & forget (no return value from save collection)
|
||||
if (collection.stations.size > 0) {
|
||||
CoroutineScope(IO).launch {
|
||||
FileHelper.backupCollectionAsPlsSuspended(
|
||||
context,
|
||||
collection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Create PLS string from collection of stations */
|
||||
fun createPlsString(collection: Collection): String {
|
||||
/* Extended PLS Format
|
||||
[playlist]
|
||||
|
||||
Title1=My Cool Stream
|
||||
File1=http://www.site.com:8000/listen.pls
|
||||
Length1=-1
|
||||
|
||||
NumberOfEntries=1
|
||||
Version=2
|
||||
*/
|
||||
|
||||
val plsString = StringBuilder()
|
||||
var counter = 1
|
||||
|
||||
// add opening tag
|
||||
plsString.append("[playlist]")
|
||||
plsString.append("\n")
|
||||
|
||||
// add name and stream address
|
||||
collection.stations.forEach { station ->
|
||||
plsString.append("\n")
|
||||
plsString.append("Title$counter=")
|
||||
plsString.append(station.name)
|
||||
plsString.append("\n")
|
||||
plsString.append("File$counter=")
|
||||
plsString.append(station.getStreamUri())
|
||||
plsString.append("\n")
|
||||
plsString.append("Length$counter=-1")
|
||||
plsString.append("\n")
|
||||
counter++
|
||||
}
|
||||
|
||||
// add ending tag
|
||||
plsString.append("\n")
|
||||
plsString.append("NumberOfEntries=${collection.stations.size}")
|
||||
plsString.append("\n")
|
||||
plsString.append("Version=2")
|
||||
|
||||
return plsString.toString()
|
||||
}
|
||||
|
||||
|
||||
/* Sends a broadcast containing the collection as parcel */
|
||||
fun sendCollectionBroadcast(context: Context, modificationDate: Date) {
|
||||
Log.v(TAG, "Broadcasting that collection has changed.")
|
||||
val collectionChangedIntent = Intent()
|
||||
collectionChangedIntent.action = Keys.ACTION_COLLECTION_CHANGED
|
||||
collectionChangedIntent.putExtra(
|
||||
Keys.EXTRA_COLLECTION_MODIFICATION_DATE,
|
||||
modificationDate.time
|
||||
)
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(collectionChangedIntent)
|
||||
}
|
||||
|
||||
|
||||
// /* Creates MediaMetadata for a single station - used in media session*/
|
||||
// fun buildStationMediaMetadata(context: Context, station: Station, metadata: String): MediaMetadataCompat {
|
||||
// return MediaMetadataCompat.Builder().apply {
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_ARTIST, station.name)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, metadata)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM, context.getString(R.string.app_name))
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, station.getStreamUri())
|
||||
// putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, ImageHelper.getScaledStationImage(context, station.image, Keys.SIZE_COVER_LOCK_SCREEN))
|
||||
// //putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, station.image)
|
||||
// }.build()
|
||||
// }
|
||||
//
|
||||
//
|
||||
// /* Creates MediaItem for a station - used by collection provider */
|
||||
// fun buildStationMediaMetaItem(context: Context, station: Station): MediaBrowserCompat.MediaItem {
|
||||
// val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
// mediaDescriptionBuilder.setMediaId(station.uuid)
|
||||
// mediaDescriptionBuilder.setTitle(station.name)
|
||||
// mediaDescriptionBuilder.setIconBitmap(ImageHelper.getScaledStationImage(context, station.image, Keys.SIZE_COVER_LOCK_SCREEN))
|
||||
// // mediaDescriptionBuilder.setIconUri(station.image.toUri())
|
||||
// return MediaBrowserCompat.MediaItem(mediaDescriptionBuilder.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
// }
|
||||
//
|
||||
//
|
||||
// /* Creates description for a station - used in MediaSessionConnector */
|
||||
// fun buildStationMediaDescription(context: Context, station: Station, metadata: String): MediaDescriptionCompat {
|
||||
// val coverBitmap: Bitmap = ImageHelper.getScaledStationImage(context, station.image, Keys.SIZE_COVER_LOCK_SCREEN)
|
||||
// val extras: Bundle = Bundle()
|
||||
// extras.putParcelable(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, coverBitmap)
|
||||
// extras.putParcelable(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, coverBitmap)
|
||||
// return MediaDescriptionCompat.Builder().apply {
|
||||
// setMediaId(station.uuid)
|
||||
// setIconBitmap(coverBitmap)
|
||||
// setIconUri(station.image.toUri())
|
||||
// setTitle(metadata)
|
||||
// setSubtitle(station.name)
|
||||
// setExtras(extras)
|
||||
// }.build()
|
||||
// }
|
||||
|
||||
|
||||
/* Creates a MediaItem with MediaMetadata for a single radio station - used to prepare player */
|
||||
fun buildMediaItem(context: Context, station: Station): MediaItem {
|
||||
// todo implement HLS MediaItems
|
||||
// put uri in RequestMetadata - credit: https://stackoverflow.com/a/70103460
|
||||
val requestMetadata = MediaItem.RequestMetadata.Builder().apply {
|
||||
setMediaUri(station.getStreamUri().toUri())
|
||||
}.build()
|
||||
// build MediaMetadata
|
||||
val mediaMetadata = MediaMetadata.Builder().apply {
|
||||
setArtist(station.name)
|
||||
//setTitle(station.name)
|
||||
/* check for "file://" prevents a crash when an old backup was restored */
|
||||
if (station.image.isNotEmpty() && station.image.startsWith("file://")) {
|
||||
//setArtworkUri(station.image.toUri())
|
||||
setArtworkData(station.image.toUri().toFile().readBytes(), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
|
||||
} else {
|
||||
//setArtworkUri(Uri.parse(Keys.LOCATION_RESOURCES + R.raw.ic_default_station_image))
|
||||
setArtworkData(ImageHelper.getStationImageAsByteArray(context), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
|
||||
}
|
||||
setIsBrowsable(false)
|
||||
setIsPlayable(true)
|
||||
}.build()
|
||||
// build MediaItem and return it
|
||||
return MediaItem.Builder().apply {
|
||||
setMediaId(station.uuid)
|
||||
setRequestMetadata(requestMetadata)
|
||||
setMediaMetadata(mediaMetadata)
|
||||
//setMimeType(station.getMediaType())
|
||||
setUri(station.getStreamUri().toUri())
|
||||
}.build()
|
||||
}
|
||||
|
||||
|
||||
/* Sorts radio stations */
|
||||
fun sortCollection(collection: Collection): Collection {
|
||||
val favoriteStations = collection.stations.filter { it.starred }
|
||||
val otherStations = collection.stations.filter { !it.starred }
|
||||
collection.stations = (favoriteStations + otherStations) as MutableList<Station>
|
||||
return collection
|
||||
}
|
||||
|
||||
|
||||
/* Get favicon address */
|
||||
private fun getFaviconAddress(urlString: String): String {
|
||||
var faviconAddress = String()
|
||||
try {
|
||||
var host: String = URL(urlString).host
|
||||
if (!host.startsWith("www")) {
|
||||
val index = host.indexOf(".")
|
||||
host = "www" + host.substring(index)
|
||||
}
|
||||
faviconAddress = "http://$host/favicon.ico"
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to get base URL from $urlString.\n$e ")
|
||||
}
|
||||
return faviconAddress
|
||||
}
|
||||
|
||||
}
|
||||
103
app/src/main/java/com/michatec/radio/helpers/DateTimeHelper.kt
Normal file
103
app/src/main/java/com/michatec/radio/helpers/DateTimeHelper.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* DateTimeHelper.kt
|
||||
* Implements the DateTimeHelper object
|
||||
* A DateTimeHelper provides helper methods for converting Date and Time objects
|
||||
*
|
||||
* 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.util.Log
|
||||
import com.michatec.radio.Keys
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* DateTimeHelper object
|
||||
*/
|
||||
object DateTimeHelper {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = DateTimeHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* Main class variables */
|
||||
private const val pattern: String = "EEE, dd MMM yyyy HH:mm:ss Z"
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat(pattern, Locale.ENGLISH)
|
||||
|
||||
|
||||
/* Converts RFC 2822 string representation of a date to DATE */
|
||||
fun convertFromRfc2822(dateString: String): Date {
|
||||
val date: Date = try {
|
||||
// parse date string using standard pattern
|
||||
dateFormat.parse((dateString)) ?: Keys.DEFAULT_DATE
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to parse. Trying an alternative Date format. $e")
|
||||
// try alternative parsing patterns
|
||||
tryAlternativeRfc2822Parsing(dateString)
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
|
||||
/* Converts a DATE to its RFC 2822 string representation */
|
||||
fun convertToRfc2822(date: Date): String {
|
||||
val dateFormat = SimpleDateFormat(pattern, Locale.ENGLISH)
|
||||
return dateFormat.format(date)
|
||||
}
|
||||
|
||||
|
||||
/* Converts a milliseconds into a readable format (HH:mm:ss) */
|
||||
fun convertToHoursMinutesSeconds(milliseconds: Long, negativeValue: Boolean = false): String {
|
||||
// convert milliseconds to hours, minutes, and seconds
|
||||
val hours: Long = milliseconds / 1000 / 3600
|
||||
val minutes: Long = milliseconds / 1000 % 3600 / 60
|
||||
val seconds: Long = milliseconds / 1000 % 60
|
||||
val hourPart = if (hours > 0) {
|
||||
"${hours.toString().padStart(2, '0')}:"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
var timeString =
|
||||
"$hourPart${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}"
|
||||
if (negativeValue) {
|
||||
// add a minus sign if a negative values was requested
|
||||
timeString = "-$timeString"
|
||||
}
|
||||
return timeString
|
||||
}
|
||||
|
||||
|
||||
/* Converts RFC 2822 string representation of a date to DATE - using alternative patterns */
|
||||
private fun tryAlternativeRfc2822Parsing(dateString: String): Date {
|
||||
var date: Date = Keys.DEFAULT_DATE
|
||||
try {
|
||||
// try to parse without seconds
|
||||
date = SimpleDateFormat("EEE, dd MMM yyyy HH:mm Z", Locale.ENGLISH).parse((dateString))
|
||||
?: Keys.DEFAULT_DATE
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
Log.w(TAG, "Unable to parse. Trying an alternative Date format. $e")
|
||||
// try to parse without time zone
|
||||
date = SimpleDateFormat(
|
||||
"EEE, dd MMM yyyy HH:mm:ss",
|
||||
Locale.ENGLISH
|
||||
).parse((dateString)) ?: Keys.DEFAULT_DATE
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to parse. Returning a default date. $e")
|
||||
}
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* DownloadFinishedReceiver.kt
|
||||
* Implements the DownloadFinishedReceiver class
|
||||
* A DownloadFinishedReceiver listens for finished downloads
|
||||
*
|
||||
* 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.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
|
||||
/*
|
||||
* DownloadFinishedReceiver class
|
||||
*/
|
||||
class DownloadFinishedReceiver : BroadcastReceiver() {
|
||||
|
||||
/* Overrides onReceive */
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// process the finished download
|
||||
DownloadHelper.processDownload(
|
||||
context,
|
||||
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
|
||||
)
|
||||
}
|
||||
}
|
||||
396
app/src/main/java/com/michatec/radio/helpers/DownloadHelper.kt
Normal file
396
app/src/main/java/com/michatec/radio/helpers/DownloadHelper.kt
Normal file
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* DownloadHelper.kt
|
||||
* Implements the DownloadHelper object
|
||||
* A DownloadHelper provides helper methods for downloading files
|
||||
*
|
||||
* 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.DownloadManager
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.net.toUri
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.R
|
||||
import com.michatec.radio.core.Collection
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.extensions.copy
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* DownloadHelper object
|
||||
*/
|
||||
object DownloadHelper {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = DownloadHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* Main class variables */
|
||||
private lateinit var collection: Collection
|
||||
private lateinit var downloadManager: DownloadManager
|
||||
private lateinit var activeDownloads: ArrayList<Long>
|
||||
private lateinit var modificationDate: Date
|
||||
|
||||
|
||||
/* Download station playlists */
|
||||
fun downloadPlaylists(context: Context, playlistUrlStrings: Array<String>) {
|
||||
// initialize main class variables, if necessary
|
||||
initialize(context)
|
||||
// convert array
|
||||
val uris: Array<Uri> =
|
||||
Array(playlistUrlStrings.size) { index -> playlistUrlStrings[index].toUri() }
|
||||
// enqueue playlists
|
||||
enqueueDownload(context, uris, Keys.FILE_TYPE_PLAYLIST)
|
||||
}
|
||||
|
||||
|
||||
/* Refresh image of given station */
|
||||
fun updateStationImage(context: Context, station: Station) {
|
||||
// initialize main class variables, if necessary
|
||||
initialize(context)
|
||||
// check if station has an image reference
|
||||
if (station.remoteImageLocation.isNotEmpty()) {
|
||||
CollectionHelper.clearImagesFolder(context, station)
|
||||
val uris: Array<Uri> = Array(1) { station.remoteImageLocation.toUri() }
|
||||
enqueueDownload(context, uris, Keys.FILE_TYPE_IMAGE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Updates all station images */
|
||||
fun updateStationImages(context: Context) {
|
||||
// initialize main class variables, if necessary
|
||||
initialize(context)
|
||||
// re-download all station images
|
||||
PreferencesHelper.saveLastUpdateCollection()
|
||||
val uris: MutableList<Uri> = mutableListOf()
|
||||
collection.stations.forEach { station ->
|
||||
station.radioBrowserStationUuid
|
||||
if (!station.imageManuallySet) {
|
||||
uris.add(station.remoteImageLocation.toUri())
|
||||
}
|
||||
}
|
||||
enqueueDownload(context, uris.toTypedArray(), Keys.FILE_TYPE_IMAGE)
|
||||
Log.i(TAG, "Updating all station images.")
|
||||
}
|
||||
|
||||
|
||||
/* Processes a given download ID */
|
||||
fun processDownload(context: Context, downloadId: Long) {
|
||||
// initialize main class variables, if necessary
|
||||
initialize(context)
|
||||
// get local Uri in content://downloads/all_downloads/ for download ID
|
||||
val downloadResult: Uri? = downloadManager.getUriForDownloadedFile(downloadId)
|
||||
if (downloadResult == null) {
|
||||
val downloadErrorCode: Int = getDownloadError(downloadId)
|
||||
val downloadErrorFileName: String = getDownloadFileName(downloadManager, downloadId)
|
||||
Toast.makeText(
|
||||
context,
|
||||
"${context.getString(R.string.toastmessage_error_download_error)}: $downloadErrorFileName ($downloadErrorCode)",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
Log.w(
|
||||
TAG,
|
||||
"Download not successful: File name = $downloadErrorFileName Error code = $downloadErrorCode"
|
||||
)
|
||||
removeFromActiveDownloads(arrayOf(downloadId), deleteDownload = true)
|
||||
return
|
||||
} else {
|
||||
val localFileUri: Uri = downloadResult
|
||||
// get remote URL for download ID
|
||||
val remoteFileLocation: String = getRemoteFileLocation(downloadManager, downloadId)
|
||||
// determine what to do
|
||||
val fileType = FileHelper.getContentType(context, localFileUri)
|
||||
if ((fileType in Keys.MIME_TYPES_M3U || fileType in Keys.MIME_TYPES_PLS) && CollectionHelper.isNewStation(
|
||||
collection,
|
||||
remoteFileLocation
|
||||
)
|
||||
) {
|
||||
addStation(context, localFileUri, remoteFileLocation)
|
||||
} else if ((fileType in Keys.MIME_TYPES_M3U || fileType in Keys.MIME_TYPES_PLS) && !CollectionHelper.isNewStation(
|
||||
collection,
|
||||
remoteFileLocation
|
||||
)
|
||||
) {
|
||||
updateStation(context, localFileUri, remoteFileLocation)
|
||||
} else if (fileType in Keys.MIME_TYPES_IMAGE) {
|
||||
collection = CollectionHelper.setStationImageWithRemoteLocation(
|
||||
context,
|
||||
collection,
|
||||
localFileUri.toString(),
|
||||
remoteFileLocation,
|
||||
false
|
||||
)
|
||||
} else if (fileType in Keys.MIME_TYPES_FAVICON) {
|
||||
collection = CollectionHelper.setStationImageWithRemoteLocation(
|
||||
context,
|
||||
collection,
|
||||
localFileUri.toString(),
|
||||
remoteFileLocation,
|
||||
false
|
||||
)
|
||||
}
|
||||
// remove ID from active downloads
|
||||
removeFromActiveDownloads(arrayOf(downloadId))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Initializes main class variables of DownloadHelper, if necessary */
|
||||
private fun initialize(context: Context) {
|
||||
if (!this::modificationDate.isInitialized) {
|
||||
modificationDate = PreferencesHelper.loadCollectionModificationDate()
|
||||
}
|
||||
if (!this::collection.isInitialized || CollectionHelper.isNewerCollectionAvailable(
|
||||
modificationDate
|
||||
)
|
||||
) {
|
||||
collection = FileHelper.readCollection(context)
|
||||
modificationDate = PreferencesHelper.loadCollectionModificationDate()
|
||||
}
|
||||
if (!this::downloadManager.isInitialized) {
|
||||
FileHelper.clearFolder(context.getExternalFilesDir(Keys.FOLDER_TEMP), 0)
|
||||
downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
}
|
||||
if (!this::activeDownloads.isInitialized) {
|
||||
activeDownloads = getActiveDownloads()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Enqueues an Array of files in DownloadManager */
|
||||
private fun enqueueDownload(
|
||||
context: Context,
|
||||
uris: Array<Uri>,
|
||||
type: Int,
|
||||
ignoreWifiRestriction: Boolean = false
|
||||
) {
|
||||
// determine allowed network types
|
||||
val allowedNetworkTypes: Int = determineAllowedNetworkTypes(type, ignoreWifiRestriction)
|
||||
// enqueue downloads
|
||||
val newIds = LongArray(uris.size)
|
||||
for (i in uris.indices) {
|
||||
Log.v(TAG, "DownloadManager enqueue: ${uris[i]}")
|
||||
// check if valid url and prevent double download
|
||||
val uri: Uri = uris[i]
|
||||
val scheme: String = uri.scheme ?: String()
|
||||
val pathSegments: List<String> = uri.pathSegments
|
||||
if (scheme.startsWith("http") && isNotInDownloadQueue(uri.toString()) && pathSegments.isNotEmpty()) {
|
||||
val fileName: String = pathSegments.last()
|
||||
val request: DownloadManager.Request = DownloadManager.Request(uri)
|
||||
.setAllowedNetworkTypes(allowedNetworkTypes)
|
||||
.setTitle(fileName)
|
||||
.setDestinationInExternalFilesDir(context, Keys.FOLDER_TEMP, fileName)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||
newIds[i] = downloadManager.enqueue(request)
|
||||
activeDownloads.add(newIds[i])
|
||||
}
|
||||
}
|
||||
setActiveDownloads(activeDownloads)
|
||||
}
|
||||
|
||||
|
||||
/* Checks if a file is not yet in download queue */
|
||||
private fun isNotInDownloadQueue(remoteFileLocation: String): Boolean {
|
||||
val activeDownloadsCopy = activeDownloads.copy()
|
||||
activeDownloadsCopy.forEach { downloadId ->
|
||||
if (getRemoteFileLocation(downloadManager, downloadId) == remoteFileLocation) {
|
||||
Log.w(TAG, "File is already in download queue: $remoteFileLocation")
|
||||
return false
|
||||
}
|
||||
}
|
||||
Log.v(TAG, "File is not in download queue.")
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/* Safely remove given download IDs from activeDownloads and delete download if requested */
|
||||
private fun removeFromActiveDownloads(
|
||||
downloadIds: Array<Long>,
|
||||
deleteDownload: Boolean = false
|
||||
): Boolean {
|
||||
// remove download ids from activeDownloads
|
||||
val success: Boolean =
|
||||
activeDownloads.removeAll { downloadId -> downloadIds.contains(downloadId) }
|
||||
if (success) {
|
||||
setActiveDownloads(activeDownloads)
|
||||
}
|
||||
// optionally: delete download
|
||||
if (deleteDownload) {
|
||||
downloadIds.forEach { downloadId -> downloadManager.remove(downloadId) }
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
|
||||
/* Reads station playlist file and adds it to collection */
|
||||
private fun addStation(context: Context, localFileUri: Uri, remoteFileLocation: String) {
|
||||
// read station playlist
|
||||
val station: Station = CollectionHelper.createStationFromPlaylistFile(
|
||||
context,
|
||||
localFileUri,
|
||||
remoteFileLocation
|
||||
)
|
||||
// detect content type on background thread
|
||||
CoroutineScope(IO).launch {
|
||||
val deferred: Deferred<NetworkHelper.ContentType> =
|
||||
async(Dispatchers.Default) { NetworkHelper.detectContentTypeSuspended(station.getStreamUri()) }
|
||||
// wait for result
|
||||
val contentType: NetworkHelper.ContentType = deferred.await()
|
||||
// set content type
|
||||
station.streamContent = contentType.type
|
||||
// add station and save collection
|
||||
withContext(Main) {
|
||||
collection = CollectionHelper.addStation(context, collection, station)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Reads station playlist file and updates it in collection */
|
||||
private fun updateStation(context: Context, localFileUri: Uri, remoteFileLocation: String) {
|
||||
// read station playlist
|
||||
val station: Station = CollectionHelper.createStationFromPlaylistFile(
|
||||
context,
|
||||
localFileUri,
|
||||
remoteFileLocation
|
||||
)
|
||||
// detect content type on background thread
|
||||
CoroutineScope(IO).launch {
|
||||
val deferred: Deferred<NetworkHelper.ContentType> =
|
||||
async(Dispatchers.Default) { NetworkHelper.detectContentTypeSuspended(station.getStreamUri()) }
|
||||
// wait for result
|
||||
val contentType: NetworkHelper.ContentType = deferred.await()
|
||||
// update content type
|
||||
station.streamContent = contentType.type
|
||||
// update station and save collection
|
||||
withContext(Main) {
|
||||
collection = CollectionHelper.updateStation(context, collection, station)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Saves active downloads (IntArray) to shared preferences */
|
||||
private fun setActiveDownloads(activeDownloads: ArrayList<Long>) {
|
||||
val builder = StringBuilder()
|
||||
for (i in activeDownloads.indices) {
|
||||
builder.append(activeDownloads[i]).append(",")
|
||||
}
|
||||
var activeDownloadsString: String = builder.toString()
|
||||
if (activeDownloadsString.isEmpty()) {
|
||||
activeDownloadsString = Keys.ACTIVE_DOWNLOADS_EMPTY
|
||||
}
|
||||
PreferencesHelper.saveActiveDownloads(activeDownloadsString)
|
||||
}
|
||||
|
||||
|
||||
/* Loads active downloads (IntArray) from shared preferences */
|
||||
private fun getActiveDownloads(): ArrayList<Long> {
|
||||
var inactiveDownloadsFound = false
|
||||
val activeDownloadsList: ArrayList<Long> = arrayListOf()
|
||||
val activeDownloadsString: String = PreferencesHelper.loadActiveDownloads()
|
||||
val count = activeDownloadsString.split(",").size - 1
|
||||
val tokenizer = StringTokenizer(activeDownloadsString, ",")
|
||||
repeat(count) {
|
||||
val token = tokenizer.nextToken().toLong()
|
||||
when (isDownloadActive(token)) {
|
||||
true -> activeDownloadsList.add(token)
|
||||
false -> inactiveDownloadsFound = true
|
||||
}
|
||||
}
|
||||
if (inactiveDownloadsFound) setActiveDownloads(activeDownloadsList)
|
||||
return activeDownloadsList
|
||||
}
|
||||
|
||||
|
||||
/* Determines the remote file location (the original URL) */
|
||||
private fun getRemoteFileLocation(downloadManager: DownloadManager, downloadId: Long): String {
|
||||
var remoteFileLocation = ""
|
||||
val cursor: Cursor =
|
||||
downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
|
||||
if (cursor.count > 0) {
|
||||
cursor.moveToFirst()
|
||||
remoteFileLocation =
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_URI))
|
||||
}
|
||||
return remoteFileLocation
|
||||
}
|
||||
|
||||
|
||||
/* Determines the file name for given download id (the original URL) */
|
||||
private fun getDownloadFileName(downloadManager: DownloadManager, downloadId: Long): String {
|
||||
var remoteFileLocation = ""
|
||||
val cursor: Cursor =
|
||||
downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
|
||||
if (cursor.count > 0) {
|
||||
cursor.moveToFirst()
|
||||
remoteFileLocation =
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TITLE))
|
||||
}
|
||||
return remoteFileLocation
|
||||
}
|
||||
|
||||
|
||||
/* Checks if a given download ID represents a finished download */
|
||||
private fun isDownloadActive(downloadId: Long): Boolean {
|
||||
var downloadStatus: Int = -1
|
||||
val cursor: Cursor =
|
||||
downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
|
||||
if (cursor.count > 0) {
|
||||
cursor.moveToFirst()
|
||||
downloadStatus =
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
}
|
||||
return downloadStatus == DownloadManager.STATUS_RUNNING
|
||||
}
|
||||
|
||||
|
||||
/* Retrieves reason of download error - returns http error codes plus error codes found here check: https://developer.android.com/reference/android/app/DownloadManager */
|
||||
private fun getDownloadError(downloadId: Long): Int {
|
||||
var reason: Int = -1
|
||||
val cursor: Cursor =
|
||||
downloadManager.query(DownloadManager.Query().setFilterById(downloadId))
|
||||
if (cursor.count > 0) {
|
||||
cursor.moveToFirst()
|
||||
val downloadStatus =
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
if (downloadStatus == DownloadManager.STATUS_FAILED) {
|
||||
reason = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
|
||||
}
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
|
||||
/* Determine allowed network type */
|
||||
private fun determineAllowedNetworkTypes(type: Int, ignoreWifiRestriction: Boolean): Int {
|
||||
var allowedNetworkTypes: Int =
|
||||
(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
|
||||
// restrict download of audio files to WiFi if necessary
|
||||
if (type == Keys.FILE_TYPE_AUDIO) {
|
||||
if (!ignoreWifiRestriction && !PreferencesHelper.downloadOverMobile()) {
|
||||
allowedNetworkTypes = DownloadManager.Request.NETWORK_WIFI
|
||||
}
|
||||
}
|
||||
return allowedNetworkTypes
|
||||
}
|
||||
|
||||
}
|
||||
497
app/src/main/java/com/michatec/radio/helpers/FileHelper.kt
Normal file
497
app/src/main/java/com/michatec/radio/helpers/FileHelper.kt
Normal file
@@ -0,0 +1,497 @@
|
||||
/*
|
||||
* 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 {
|
||||
// todo read https://commonsware.com/blog/2016/03/15/how-consume-content-uri.html
|
||||
// https://developer.android.com/training/secure-file-sharing/retrieve-info
|
||||
|
||||
// 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 stream: InputStream = file.inputStream()
|
||||
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")
|
||||
}
|
||||
|
||||
}
|
||||
257
app/src/main/java/com/michatec/radio/helpers/ImageHelper.kt
Normal file
257
app/src/main/java/com/michatec/radio/helpers/ImageHelper.kt
Normal file
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* ImageHelper.kt
|
||||
* Implements the ImageHelper object
|
||||
* An ImageHelper provides helper methods for image related operations
|
||||
*
|
||||
* 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.content.Context
|
||||
import android.graphics.*
|
||||
import android.net.Uri
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.net.toUri
|
||||
import androidx.palette.graphics.Palette
|
||||
import com.michatec.radio.R
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
|
||||
/*
|
||||
* ImageHelper class
|
||||
*/
|
||||
object ImageHelper {
|
||||
|
||||
/* Get scaling factor from display density */
|
||||
fun getDensityScalingFactor(context: Context): Float {
|
||||
return context.resources.displayMetrics.density
|
||||
}
|
||||
|
||||
|
||||
/* Get a scaled version of the station image */
|
||||
fun getScaledStationImage(context: Context, imageUri: Uri, imageSize: Int): Bitmap {
|
||||
val size: Int = (imageSize * getDensityScalingFactor(context)).toInt()
|
||||
return decodeSampledBitmapFromUri(context, imageUri, size, size)
|
||||
}
|
||||
|
||||
|
||||
/* Get an unscaled version of the station image */
|
||||
fun getStationImage(context: Context, imageUriString: String): Bitmap {
|
||||
var bitmap: Bitmap? = null
|
||||
|
||||
if (imageUriString.isNotEmpty()) {
|
||||
try {
|
||||
// just decode the file
|
||||
bitmap = BitmapFactory.decodeFile(imageUriString.toUri().path)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
// get default image
|
||||
if (bitmap == null) {
|
||||
bitmap = ContextCompat.getDrawable(context, R.drawable.ic_default_station_image_72dp)!!
|
||||
.toBitmap()
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
|
||||
|
||||
/* Get an unscaled version of the station image as a ByteArray */
|
||||
fun getStationImageAsByteArray(context: Context, imageUriString: String = String()): ByteArray {
|
||||
val coverBitmap: Bitmap = getStationImage(context, imageUriString)
|
||||
val stream = ByteArrayOutputStream()
|
||||
coverBitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
val coverByteArray: ByteArray = stream.toByteArray()
|
||||
coverBitmap.recycle()
|
||||
return coverByteArray
|
||||
}
|
||||
|
||||
|
||||
/* Creates station image on a square background with the main station image color and option padding for adaptive icons */
|
||||
fun createSquareImage(
|
||||
context: Context,
|
||||
bitmap: Bitmap,
|
||||
backgroundColor: Int,
|
||||
size: Int,
|
||||
adaptivePadding: Boolean
|
||||
): Bitmap {
|
||||
|
||||
// create background
|
||||
val background = Paint()
|
||||
background.style = Paint.Style.FILL
|
||||
if (backgroundColor != -1) {
|
||||
background.color = backgroundColor
|
||||
} else {
|
||||
background.color = ContextCompat.getColor(context, R.color.default_neutral_dark)
|
||||
}
|
||||
|
||||
// create empty bitmap and canvas
|
||||
val outputImage: Bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val imageCanvas = Canvas(outputImage)
|
||||
|
||||
// draw square background
|
||||
val right = size.toFloat()
|
||||
val bottom = size.toFloat()
|
||||
imageCanvas.drawRect(0f, 0f, right, bottom, background)
|
||||
|
||||
// draw input image onto canvas using transformation matrix
|
||||
val paint = Paint()
|
||||
paint.isFilterBitmap = true
|
||||
imageCanvas.drawBitmap(
|
||||
bitmap,
|
||||
createTransformationMatrix(
|
||||
size,
|
||||
bitmap.height.toFloat(),
|
||||
bitmap.width.toFloat(),
|
||||
adaptivePadding
|
||||
),
|
||||
paint
|
||||
)
|
||||
return outputImage
|
||||
}
|
||||
|
||||
|
||||
/* Extracts color from an image */
|
||||
fun getMainColor(context: Context, imageUri: Uri): Int {
|
||||
// extract color palette from station image
|
||||
val palette: Palette =
|
||||
Palette.from(decodeSampledBitmapFromUri(context, imageUri, 72, 72)).generate()
|
||||
// get muted and vibrant swatches
|
||||
val vibrantSwatch = palette.vibrantSwatch
|
||||
val mutedSwatch = palette.mutedSwatch
|
||||
|
||||
when {
|
||||
vibrantSwatch != null -> {
|
||||
// return vibrant color
|
||||
val rgb = vibrantSwatch.rgb
|
||||
return Color.argb(255, Color.red(rgb), Color.green(rgb), Color.blue(rgb))
|
||||
}
|
||||
mutedSwatch != null -> {
|
||||
// return muted color
|
||||
val rgb = mutedSwatch.rgb
|
||||
return Color.argb(255, Color.red(rgb), Color.green(rgb), Color.blue(rgb))
|
||||
}
|
||||
else -> {
|
||||
// default return
|
||||
return context.resources.getColor(R.color.default_neutral_medium_light, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Return sampled down image for given Uri */
|
||||
private fun decodeSampledBitmapFromUri(
|
||||
context: Context,
|
||||
imageUri: Uri,
|
||||
reqWidth: Int,
|
||||
reqHeight: Int
|
||||
): Bitmap {
|
||||
var bitmap: Bitmap? = null
|
||||
if (imageUri.toString().isNotEmpty()) {
|
||||
try {
|
||||
// first decode with inJustDecodeBounds=true to check dimensions
|
||||
var stream: InputStream? = context.contentResolver.openInputStream(imageUri)
|
||||
val options = BitmapFactory.Options()
|
||||
options.inJustDecodeBounds = true
|
||||
BitmapFactory.decodeStream(stream, null, options)
|
||||
stream?.close()
|
||||
|
||||
// calculate inSampleSize
|
||||
options.inSampleSize = calculateSampleParameter(options, reqWidth, reqHeight)
|
||||
|
||||
// decode bitmap with inSampleSize set
|
||||
stream = context.contentResolver.openInputStream(imageUri)
|
||||
options.inJustDecodeBounds = false
|
||||
bitmap = BitmapFactory.decodeStream(stream, null, options)
|
||||
stream?.close()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
// get default image
|
||||
if (bitmap == null) {
|
||||
bitmap = ContextCompat.getDrawable(context, R.drawable.ic_default_station_image_72dp)!!
|
||||
.toBitmap()
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
|
||||
|
||||
/* Calculates parameter needed to scale image down */
|
||||
private fun calculateSampleParameter(
|
||||
options: BitmapFactory.Options,
|
||||
reqWidth: Int,
|
||||
reqHeight: Int
|
||||
): Int {
|
||||
// get size of original image
|
||||
val height = options.outHeight
|
||||
val width = options.outWidth
|
||||
var inSampleSize = 1
|
||||
|
||||
if (height > reqHeight || width > reqWidth) {
|
||||
|
||||
val halfHeight = height / 2
|
||||
val halfWidth = width / 2
|
||||
|
||||
// calculates the largest inSampleSize value that is a power of 2 and keeps both
|
||||
// height and width larger than the requested height and width
|
||||
while (halfHeight / inSampleSize > reqHeight && halfWidth / inSampleSize > reqWidth) {
|
||||
inSampleSize *= 2
|
||||
}
|
||||
}
|
||||
return inSampleSize
|
||||
}
|
||||
|
||||
|
||||
/* Creates a transformation matrix with the given size and optional padding */
|
||||
private fun createTransformationMatrix(
|
||||
size: Int,
|
||||
inputImageHeight: Float,
|
||||
inputImageWidth: Float,
|
||||
scaled: Boolean
|
||||
): Matrix {
|
||||
val matrix = Matrix()
|
||||
|
||||
// calculate padding
|
||||
var padding = 0f
|
||||
if (scaled) {
|
||||
padding = size.toFloat() / 4f
|
||||
}
|
||||
|
||||
// define variables needed for transformation matrix
|
||||
val aspectRatio: Float
|
||||
val xTranslation: Float
|
||||
val yTranslation: Float
|
||||
|
||||
// landscape format and square
|
||||
if (inputImageWidth >= inputImageHeight) {
|
||||
aspectRatio = (size - padding * 2) / inputImageWidth
|
||||
xTranslation = 0.0f + padding
|
||||
yTranslation = (size - inputImageHeight * aspectRatio) / 2.0f
|
||||
} else {
|
||||
aspectRatio = (size - padding * 2) / inputImageHeight
|
||||
yTranslation = 0.0f + padding
|
||||
xTranslation = (size - inputImageWidth * aspectRatio) / 2.0f
|
||||
}
|
||||
|
||||
// construct transformation matrix
|
||||
matrix.postTranslate(xTranslation, yTranslation)
|
||||
matrix.preScale(aspectRatio, aspectRatio)
|
||||
return matrix
|
||||
}
|
||||
|
||||
}
|
||||
42
app/src/main/java/com/michatec/radio/helpers/ImportHelper.kt
Normal file
42
app/src/main/java/com/michatec/radio/helpers/ImportHelper.kt
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* ImportHelper.kt
|
||||
* Implements the ImportHelper object
|
||||
* A ImportHelper provides methods for integrating station files from Radio v3
|
||||
*
|
||||
* 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.content.Context
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.core.Collection
|
||||
|
||||
|
||||
/*
|
||||
* ImportHelper object
|
||||
*/
|
||||
object ImportHelper {
|
||||
|
||||
|
||||
/* */
|
||||
fun removeDefaultStationImageUris(context: Context) {
|
||||
val collection: Collection = FileHelper.readCollection(context)
|
||||
collection.stations.forEach { station ->
|
||||
if (station.image == Keys.LOCATION_DEFAULT_STATION_IMAGE) {
|
||||
station.image = String()
|
||||
}
|
||||
if (station.smallImage == Keys.LOCATION_DEFAULT_STATION_IMAGE) {
|
||||
station.smallImage = String()
|
||||
}
|
||||
}
|
||||
CollectionHelper.saveCollection(context, collection, async = false)
|
||||
}
|
||||
|
||||
}
|
||||
167
app/src/main/java/com/michatec/radio/helpers/NetworkHelper.kt
Normal file
167
app/src/main/java/com/michatec/radio/helpers/NetworkHelper.kt
Normal file
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* NetworkHelper.kt
|
||||
* Implements the NetworkHelper object
|
||||
* A NetworkHelper provides helper methods for network related operations
|
||||
*
|
||||
* 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.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.util.Log
|
||||
import com.michatec.radio.Keys
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.InetAddress
|
||||
import java.net.URL
|
||||
import java.net.UnknownHostException
|
||||
import java.util.*
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
|
||||
/*
|
||||
* NetworkHelper object
|
||||
*/
|
||||
object NetworkHelper {
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = NetworkHelper::class.java.simpleName
|
||||
|
||||
/* Data class: holder for content type information */
|
||||
data class ContentType(var type: String = String(), var charset: String = String())
|
||||
|
||||
|
||||
/* Checks if the active network connection is connected to any network */
|
||||
fun isConnectedToNetwork(context: Context): Boolean {
|
||||
val connMgr = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val activeNetwork: Network? = connMgr.activeNetwork
|
||||
return activeNetwork != null
|
||||
}
|
||||
|
||||
|
||||
/* Detects content type (mime type) from given URL string - async using coroutine - use only on separate threat */
|
||||
fun detectContentType(urlString: String): ContentType {
|
||||
Log.v(TAG, "Determining content type - Thread: ${Thread.currentThread().name}")
|
||||
val contentType = ContentType(Keys.MIME_TYPE_UNSUPPORTED, Keys.CHARSET_UNDEFINDED)
|
||||
val connection: HttpURLConnection? = createConnection(urlString)
|
||||
if (connection != null) {
|
||||
val contentTypeHeader: String = connection.contentType ?: String()
|
||||
Log.v(TAG, "Raw content type header: $contentTypeHeader")
|
||||
val contentTypeHeaderParts: List<String> = contentTypeHeader.split(";")
|
||||
contentTypeHeaderParts.forEachIndexed { index, part ->
|
||||
if (index == 0 && part.isNotEmpty()) {
|
||||
contentType.type = part.trim()
|
||||
} else if (part.contains("charset=")) {
|
||||
contentType.charset = part.substringAfter("charset=").trim()
|
||||
}
|
||||
}
|
||||
|
||||
// special treatment for octet-stream - try to get content type from file extension
|
||||
if (contentType.type.contains(Keys.MIME_TYPE_OCTET_STREAM)) {
|
||||
Log.w(TAG, "Special case \"application/octet-stream\"")
|
||||
val headerFieldContentDisposition: String? =
|
||||
connection.getHeaderField("Content-Disposition")
|
||||
if (headerFieldContentDisposition != null) {
|
||||
val fileName: String = headerFieldContentDisposition.split("=")[1].replace(
|
||||
"\"",
|
||||
""
|
||||
) //getting value after '=' & stripping any "s
|
||||
contentType.type = FileHelper.getContentTypeFromExtension(fileName)
|
||||
} else {
|
||||
Log.i(TAG, "Unable to get file name from \"Content-Disposition\" header field.")
|
||||
}
|
||||
}
|
||||
|
||||
connection.disconnect()
|
||||
}
|
||||
Log.i(TAG, "content type: ${contentType.type} | character set: ${contentType.charset}")
|
||||
return contentType
|
||||
}
|
||||
|
||||
|
||||
/* Download playlist - up to 100 lines, with max. 200 characters */
|
||||
fun downloadPlaylist(playlistUrlString: String): List<String> {
|
||||
val lines = mutableListOf<String>()
|
||||
val connection = URL(playlistUrlString).openConnection()
|
||||
val reader = connection.getInputStream().bufferedReader()
|
||||
reader.useLines { sequence ->
|
||||
sequence.take(100).forEach { line ->
|
||||
val trimmedLine = line.take(2000)
|
||||
lines.add(trimmedLine)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
|
||||
/* Suspend function: Detects content type (mime type) from given URL string - async using coroutine */
|
||||
suspend fun detectContentTypeSuspended(urlString: String): ContentType {
|
||||
return suspendCoroutine { cont ->
|
||||
cont.resume(detectContentType(urlString))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Suspend function: Gets a random radio-browser.info api address - async using coroutine */
|
||||
suspend fun getRadioBrowserServerSuspended(): String {
|
||||
return suspendCoroutine { cont ->
|
||||
val serverAddress: String = try {
|
||||
// get all available radio browser servers
|
||||
val serverAddressList: Array<InetAddress> =
|
||||
InetAddress.getAllByName(Keys.RADIO_BROWSER_API_BASE)
|
||||
// select a random address
|
||||
serverAddressList[Random().nextInt(serverAddressList.size)].canonicalHostName
|
||||
} catch (e: UnknownHostException) {
|
||||
Keys.RADIO_BROWSER_API_DEFAULT
|
||||
}
|
||||
PreferencesHelper.saveRadioBrowserApiAddress(serverAddress)
|
||||
cont.resume(serverAddress)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Creates a http connection from given url string */
|
||||
private fun createConnection(urlString: String, redirectCount: Int = 0): HttpURLConnection? {
|
||||
var connection: HttpURLConnection? = null
|
||||
|
||||
try {
|
||||
// try to open connection and get status
|
||||
Log.i(TAG, "Opening http connection.")
|
||||
connection = URL(urlString).openConnection() as HttpURLConnection
|
||||
val status = connection.responseCode
|
||||
|
||||
// CHECK for non-HTTP_OK status
|
||||
if (status != HttpURLConnection.HTTP_OK) {
|
||||
// CHECK for redirect status
|
||||
if (status == HttpURLConnection.HTTP_MOVED_TEMP || status == HttpURLConnection.HTTP_MOVED_PERM || status == HttpURLConnection.HTTP_SEE_OTHER) {
|
||||
val redirectUrl: String = connection.getHeaderField("Location")
|
||||
connection.disconnect()
|
||||
if (redirectCount < 5) {
|
||||
Log.i(TAG, "Following redirect to $redirectUrl")
|
||||
connection = createConnection(redirectUrl, redirectCount + 1)
|
||||
} else {
|
||||
connection = null
|
||||
Log.e(TAG, "Too many redirects.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to open http connection.")
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return connection
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* PreferencesHelper.kt
|
||||
* Implements the PreferencesHelper object
|
||||
* A PreferencesHelper provides helper methods for the saving and loading values from shared preferences
|
||||
*
|
||||
* 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.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.gson.Gson
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.ui.PlayerState
|
||||
import java.util.*
|
||||
|
||||
|
||||
/*
|
||||
* PreferencesHelper object
|
||||
*/
|
||||
object PreferencesHelper {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = PreferencesHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* The sharedPreferences object to be initialized */
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
|
||||
/* Initialize a single sharedPreferences object when the app is launched */
|
||||
fun Context.initPreferences() {
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
}
|
||||
|
||||
|
||||
/* Loads address of radio-browser.info API from shared preferences */
|
||||
fun loadRadioBrowserApiAddress(): String {
|
||||
return sharedPreferences.getString(
|
||||
Keys.PREF_RADIO_BROWSER_API,
|
||||
Keys.RADIO_BROWSER_API_DEFAULT
|
||||
) ?: Keys.RADIO_BROWSER_API_DEFAULT
|
||||
}
|
||||
|
||||
|
||||
/* Saves address of radio-browser.info API to shared preferences */
|
||||
fun saveRadioBrowserApiAddress(radioBrowserApi: String) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_RADIO_BROWSER_API, radioBrowserApi)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Saves state of playback for player to shared preferences */
|
||||
fun saveIsPlaying(isPlaying: Boolean) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Keys.PREF_PLAYER_STATE_IS_PLAYING, isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Load uuid of the station in the station list which is currently expanded */
|
||||
fun loadStationListStreamUuid(): String {
|
||||
return sharedPreferences.getString(Keys.PREF_STATION_LIST_EXPANDED_UUID, String())
|
||||
?: String()
|
||||
}
|
||||
|
||||
|
||||
/* Save uuid of the station in the station list which is currently expanded */
|
||||
fun saveStationListStreamUuid(stationUuid: String = String()) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_STATION_LIST_EXPANDED_UUID, stationUuid)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Saves last update to shared preferences */
|
||||
fun saveLastUpdateCollection(lastUpdate: Date = Calendar.getInstance().time) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_LAST_UPDATE_COLLECTION, DateTimeHelper.convertToRfc2822(lastUpdate))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads size of collection from shared preferences */
|
||||
fun loadCollectionSize(): Int {
|
||||
return sharedPreferences.getInt(Keys.PREF_COLLECTION_SIZE, -1)
|
||||
}
|
||||
|
||||
|
||||
/* Saves site of collection to shared preferences */
|
||||
fun saveCollectionSize(size: Int) {
|
||||
sharedPreferences.edit {
|
||||
putInt(Keys.PREF_COLLECTION_SIZE, size)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Saves state of sleep timer to shared preferences */
|
||||
fun saveSleepTimerRunning(isRunning: Boolean) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Keys.PREF_PLAYER_STATE_SLEEP_TIMER_RUNNING, isRunning)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads date of last save operation from shared preferences */
|
||||
fun loadCollectionModificationDate(): Date {
|
||||
val modificationDateString: String =
|
||||
sharedPreferences.getString(Keys.PREF_COLLECTION_MODIFICATION_DATE, "") ?: String()
|
||||
return DateTimeHelper.convertFromRfc2822(modificationDateString)
|
||||
}
|
||||
|
||||
|
||||
/* Saves date of last save operation to shared preferences */
|
||||
fun saveCollectionModificationDate(lastSave: Date = Calendar.getInstance().time) {
|
||||
sharedPreferences.edit {
|
||||
putString(
|
||||
Keys.PREF_COLLECTION_MODIFICATION_DATE,
|
||||
DateTimeHelper.convertToRfc2822(lastSave)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads active downloads from shared preferences */
|
||||
fun loadActiveDownloads(): String {
|
||||
val activeDownloadsString: String =
|
||||
sharedPreferences.getString(Keys.PREF_ACTIVE_DOWNLOADS, Keys.ACTIVE_DOWNLOADS_EMPTY)
|
||||
?: Keys.ACTIVE_DOWNLOADS_EMPTY
|
||||
Log.v(TAG, "IDs of active downloads: $activeDownloadsString")
|
||||
return activeDownloadsString
|
||||
}
|
||||
|
||||
|
||||
/* Saves active downloads to shared preferences */
|
||||
fun saveActiveDownloads(activeDownloadsString: String = String()) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_ACTIVE_DOWNLOADS, activeDownloadsString)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads state of player user interface from shared preferences */
|
||||
fun loadPlayerState(): PlayerState {
|
||||
return PlayerState().apply {
|
||||
stationUuid = sharedPreferences.getString(Keys.PREF_PLAYER_STATE_STATION_UUID, String())
|
||||
?: String()
|
||||
isPlaying = sharedPreferences.getBoolean(Keys.PREF_PLAYER_STATE_IS_PLAYING, false)
|
||||
sleepTimerRunning =
|
||||
sharedPreferences.getBoolean(Keys.PREF_PLAYER_STATE_SLEEP_TIMER_RUNNING, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Saves Uuid if currently playing station to shared preferences */
|
||||
fun saveCurrentStationId(stationUuid: String) {
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_PLAYER_STATE_STATION_UUID, stationUuid)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads uuid of last played station from shared preferences */
|
||||
fun loadLastPlayedStationUuid(): String {
|
||||
return sharedPreferences.getString(Keys.PREF_PLAYER_STATE_STATION_UUID, String())
|
||||
?: String()
|
||||
}
|
||||
|
||||
|
||||
/* Saves history of metadata in shared preferences */
|
||||
fun saveMetadataHistory(metadataHistory: MutableList<String>) {
|
||||
val gson = Gson()
|
||||
val json = gson.toJson(metadataHistory)
|
||||
sharedPreferences.edit {
|
||||
putString(Keys.PREF_PLAYER_METADATA_HISTORY, json)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads history of metadata from shared preferences */
|
||||
fun loadMetadataHistory(): MutableList<String> {
|
||||
var metadataHistory: MutableList<String> = mutableListOf()
|
||||
val json: String =
|
||||
sharedPreferences.getString(Keys.PREF_PLAYER_METADATA_HISTORY, String()) ?: String()
|
||||
if (json.isNotEmpty()) {
|
||||
val gson = Gson()
|
||||
metadataHistory = gson.fromJson(json, metadataHistory::class.java)
|
||||
}
|
||||
return metadataHistory
|
||||
}
|
||||
|
||||
|
||||
/* Start watching for changes in shared preferences - context must implement OnSharedPreferenceChangeListener */
|
||||
fun registerPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
|
||||
/* Stop watching for changes in shared preferences - context must implement OnSharedPreferenceChangeListener */
|
||||
fun unregisterPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
|
||||
/* Checks if housekeeping work needs to be done - used usually in DownloadWorker "REQUEST_UPDATE_COLLECTION" */
|
||||
fun isHouseKeepingNecessary(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_ONE_TIME_HOUSEKEEPING_NECESSARY, true)
|
||||
}
|
||||
|
||||
|
||||
/* Saves state of housekeeping */
|
||||
fun saveHouseKeepingNecessaryState(state: Boolean = false) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Keys.PREF_ONE_TIME_HOUSEKEEPING_NECESSARY, state)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Load currently selected app theme */
|
||||
fun loadThemeSelection(): String {
|
||||
return sharedPreferences.getString(
|
||||
Keys.PREF_THEME_SELECTION,
|
||||
Keys.STATE_THEME_FOLLOW_SYSTEM
|
||||
) ?: Keys.STATE_THEME_FOLLOW_SYSTEM
|
||||
}
|
||||
|
||||
|
||||
/* Loads value of the option: Edit Stations */
|
||||
fun loadEditStationsEnabled(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_EDIT_STATIONS, true)
|
||||
}
|
||||
|
||||
|
||||
/* Saves value of the option: Edit Stations (only needed for migration) */
|
||||
fun saveEditStationsEnabled(enabled: Boolean = false) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Keys.PREF_EDIT_STATIONS, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Loads value of the option: Edit Station Streams */
|
||||
fun loadEditStreamUrisEnabled(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_EDIT_STREAMS_URIS, true)
|
||||
}
|
||||
|
||||
|
||||
/* Loads value of the option: Buffer Size */
|
||||
fun loadLargeBufferSize(): Boolean {
|
||||
return sharedPreferences.getBoolean(Keys.PREF_LARGE_BUFFER_SIZE, false)
|
||||
}
|
||||
|
||||
|
||||
/* Loads a multiplier value for constructing the load control */
|
||||
fun loadBufferSizeMultiplier(): Int {
|
||||
return if (sharedPreferences.getBoolean(Keys.PREF_LARGE_BUFFER_SIZE, false)) {
|
||||
Keys.LARGE_BUFFER_SIZE_MULTIPLIER
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Return whether to download over mobile */
|
||||
fun downloadOverMobile(): Boolean {
|
||||
return sharedPreferences.getBoolean(
|
||||
Keys.PREF_DOWNLOAD_OVER_MOBILE,
|
||||
Keys.DEFAULT_DOWNLOAD_OVER_MOBILE
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* ShortcutHelper.kt
|
||||
* Implements the ShortcutHelper object
|
||||
* A ShortcutHelper creates and handles station shortcuts on the Home screen
|
||||
*
|
||||
* 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.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.MainActivity
|
||||
import com.michatec.radio.R
|
||||
import com.michatec.radio.core.Station
|
||||
|
||||
|
||||
/*
|
||||
* ShortcutHelper object
|
||||
*/
|
||||
object ShortcutHelper {
|
||||
|
||||
/* Places shortcut on Home screen */
|
||||
fun placeShortcut(context: Context, station: Station) {
|
||||
// credit: https://medium.com/@BladeCoder/using-support-library-26-0-0-you-can-do-bb75911e01e8
|
||||
if (ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
|
||||
val shortcut: ShortcutInfoCompat = ShortcutInfoCompat.Builder(context, station.name)
|
||||
.setShortLabel(station.name)
|
||||
.setLongLabel(station.name)
|
||||
.setIcon(createShortcutIcon(context, station.image, station.imageColor))
|
||||
.setIntent(createShortcutIntent(context, station.uuid))
|
||||
.build()
|
||||
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
|
||||
Toast.makeText(context, R.string.toastmessage_shortcut_created, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
} else {
|
||||
Toast.makeText(context, R.string.toastmessage_shortcut_not_created, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Creates Intent for a station shortcut */
|
||||
private fun createShortcutIntent(context: Context, stationUuid: String): Intent {
|
||||
val shortcutIntent = Intent(context, MainActivity::class.java)
|
||||
shortcutIntent.action = Keys.ACTION_START
|
||||
shortcutIntent.putExtra(Keys.EXTRA_STATION_UUID, stationUuid)
|
||||
shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
shortcutIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
return shortcutIntent
|
||||
}
|
||||
|
||||
|
||||
/* Create shortcut icon */
|
||||
private fun createShortcutIcon(
|
||||
context: Context,
|
||||
stationImage: String,
|
||||
stationImageColor: Int
|
||||
): IconCompat {
|
||||
val stationImageBitmap: Bitmap =
|
||||
ImageHelper.getScaledStationImage(context, stationImage.toUri(), 192)
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
IconCompat.createWithAdaptiveBitmap(
|
||||
ImageHelper.createSquareImage(
|
||||
context,
|
||||
stationImageBitmap,
|
||||
stationImageColor,
|
||||
192,
|
||||
true
|
||||
)
|
||||
)
|
||||
} else {
|
||||
IconCompat.createWithAdaptiveBitmap(
|
||||
ImageHelper.createSquareImage(
|
||||
context,
|
||||
stationImageBitmap,
|
||||
stationImageColor,
|
||||
192,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
282
app/src/main/java/com/michatec/radio/helpers/UiHelper.kt
Normal file
282
app/src/main/java/com/michatec/radio/helpers/UiHelper.kt
Normal file
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* UiHelper.kt
|
||||
* Implements the UiHelper object
|
||||
* A UiHelper provides helper methods for User Interface related tasks
|
||||
*
|
||||
* 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.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffXfermode
|
||||
import android.graphics.RectF
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.R
|
||||
|
||||
|
||||
/*
|
||||
* UiHelper object
|
||||
*/
|
||||
object UiHelper {
|
||||
|
||||
/* Sets layout margins for given view in DP */
|
||||
fun setViewMargins(
|
||||
context: Context,
|
||||
view: View,
|
||||
left: Int = 0,
|
||||
right: Int = 0,
|
||||
top: Int = 0,
|
||||
bottom: Int = 0
|
||||
) {
|
||||
val l: Int = (left * ImageHelper.getDensityScalingFactor(context)).toInt()
|
||||
val r: Int = (right * ImageHelper.getDensityScalingFactor(context)).toInt()
|
||||
val t: Int = (top * ImageHelper.getDensityScalingFactor(context)).toInt()
|
||||
val b: Int = (bottom * ImageHelper.getDensityScalingFactor(context)).toInt()
|
||||
if (view.layoutParams is ViewGroup.MarginLayoutParams) {
|
||||
val p = view.layoutParams as ViewGroup.MarginLayoutParams
|
||||
p.setMargins(l, t, r, b)
|
||||
view.requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Hide keyboard */
|
||||
fun hideSoftKeyboard(context: Context, view: View) {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Inner class: Callback that detects a swipe to left
|
||||
* Credit: https://github.com/kitek/android-rv-swipe-delete/blob/master/app/src/main/java/pl/kitek/rvswipetodelete/SwipeToDeleteCallback.kt
|
||||
*/
|
||||
abstract class SwipeToDeleteCallback(context: Context) :
|
||||
ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
|
||||
|
||||
private val deleteIcon = ContextCompat.getDrawable(context, R.drawable.ic_remove_circle_24dp)
|
||||
private val intrinsicWidth: Int = deleteIcon?.intrinsicWidth ?: 0
|
||||
private val intrinsicHeight: Int = deleteIcon?.intrinsicHeight ?: 0
|
||||
private val backgroundColor = ContextCompat.getColor(context, R.color.list_card_delete_background)
|
||||
private val clearPaint: Paint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) }
|
||||
private val cornerRadius: Float = dpToPx(context)
|
||||
|
||||
override fun getMovementFlags(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
): Int {
|
||||
// disable swipe for the add new card
|
||||
if (viewHolder.itemViewType == Keys.VIEW_TYPE_ADD_NEW) {
|
||||
return 0
|
||||
}
|
||||
return super.getMovementFlags(recyclerView, viewHolder)
|
||||
}
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
// do nothing
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onChildDraw(
|
||||
c: Canvas,
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
dX: Float,
|
||||
dY: Float,
|
||||
actionState: Int,
|
||||
isCurrentlyActive: Boolean
|
||||
) {
|
||||
val itemView = viewHolder.itemView
|
||||
val itemHeight = itemView.bottom - itemView.top
|
||||
val isCanceled = dX == 0f && !isCurrentlyActive
|
||||
|
||||
if (isCanceled) {
|
||||
clearCanvas(
|
||||
c,
|
||||
itemView.right + dX,
|
||||
itemView.top.toFloat(),
|
||||
itemView.right.toFloat(),
|
||||
itemView.bottom.toFloat()
|
||||
)
|
||||
super.onChildDraw(
|
||||
c,
|
||||
recyclerView,
|
||||
viewHolder,
|
||||
dX,
|
||||
dY,
|
||||
actionState,
|
||||
false
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// draw delete and rounded background
|
||||
val roundedBackground = RectF(
|
||||
itemView.left.toFloat(),
|
||||
itemView.top.toFloat(),
|
||||
itemView.right.toFloat(),
|
||||
itemView.bottom.toFloat()
|
||||
)
|
||||
|
||||
val paint = Paint()
|
||||
paint.color = backgroundColor
|
||||
c.drawRoundRect(roundedBackground, cornerRadius, cornerRadius, paint)
|
||||
|
||||
// calculate position of delete icon
|
||||
val deleteIconTop = itemView.top + (itemHeight - intrinsicHeight) / 2
|
||||
val deleteIconMargin = (itemHeight - intrinsicHeight) / 2
|
||||
val deleteIconLeft = itemView.right - deleteIconMargin - intrinsicWidth
|
||||
val deleteIconRight = itemView.right - deleteIconMargin
|
||||
val deleteIconBottom = deleteIconTop + intrinsicHeight
|
||||
|
||||
// draw delete icon
|
||||
deleteIcon?.setBounds(deleteIconLeft, deleteIconTop, deleteIconRight, deleteIconBottom)
|
||||
deleteIcon?.draw(c)
|
||||
|
||||
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
|
||||
}
|
||||
|
||||
private fun clearCanvas(c: Canvas?, left: Float, top: Float, right: Float, bottom: Float) {
|
||||
c?.drawRect(left, top, right, bottom, clearPaint)
|
||||
}
|
||||
|
||||
// conversion from dp to px
|
||||
private fun dpToPx(context: Context): Float {
|
||||
val density = context.resources.displayMetrics.density
|
||||
return 24 * density
|
||||
}
|
||||
}
|
||||
/*
|
||||
* End of inner class
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Inner class: Callback that detects a swipe to left
|
||||
* Credit: https://github.com/kitek/android-rv-swipe-delete/blob/master/app/src/main/java/pl/kitek/rvswipetodelete/SwipeToDeleteCallback.kt
|
||||
*/
|
||||
abstract class SwipeToMarkStarredCallback(context: Context) :
|
||||
ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.RIGHT) {
|
||||
|
||||
private val starIcon = ContextCompat.getDrawable(context, R.drawable.ic_favorite_24dp)
|
||||
private val intrinsicWidth: Int = starIcon?.intrinsicWidth ?: 0
|
||||
private val intrinsicHeight: Int = starIcon?.intrinsicHeight ?: 0
|
||||
private val backgroundColor = ContextCompat.getColor(context, R.color.list_card_mark_starred_background)
|
||||
private val clearPaint: Paint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) }
|
||||
private val cornerRadius: Float = dpToPx(context)
|
||||
|
||||
override fun getMovementFlags(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
): Int {
|
||||
// disable swipe for the add new card
|
||||
if (viewHolder.itemViewType == Keys.VIEW_TYPE_ADD_NEW) {
|
||||
return 0
|
||||
}
|
||||
return super.getMovementFlags(recyclerView, viewHolder)
|
||||
}
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
// do nothing
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onChildDraw(
|
||||
c: Canvas,
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
dX: Float,
|
||||
dY: Float,
|
||||
actionState: Int,
|
||||
isCurrentlyActive: Boolean
|
||||
) {
|
||||
val itemView = viewHolder.itemView
|
||||
val itemHeight = itemView.bottom - itemView.top
|
||||
val isCanceled = dX == 0f && !isCurrentlyActive
|
||||
|
||||
if (isCanceled) {
|
||||
clearCanvas(
|
||||
c,
|
||||
itemView.right + dX,
|
||||
itemView.top.toFloat(),
|
||||
itemView.right.toFloat(),
|
||||
itemView.bottom.toFloat()
|
||||
)
|
||||
super.onChildDraw(
|
||||
c,
|
||||
recyclerView,
|
||||
viewHolder,
|
||||
dX,
|
||||
dY,
|
||||
actionState,
|
||||
false
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// draw favorite color and rounded background
|
||||
val roundedBackground = RectF(
|
||||
itemView.left.toFloat(),
|
||||
itemView.top.toFloat(),
|
||||
itemView.right.toFloat(),
|
||||
itemView.bottom.toFloat()
|
||||
)
|
||||
|
||||
val paint = Paint()
|
||||
paint.color = backgroundColor
|
||||
c.drawRoundRect(roundedBackground, cornerRadius, cornerRadius, paint)
|
||||
|
||||
// calculate position of delete icon
|
||||
val deleteIconTop = itemView.top + (itemHeight - intrinsicHeight) / 2
|
||||
val deleteIconMargin = (itemHeight - intrinsicHeight) / 2
|
||||
val deleteIconLeft = itemView.left + deleteIconMargin
|
||||
val deleteIconRight = itemView.left + deleteIconMargin + intrinsicWidth
|
||||
val deleteIconBottom = deleteIconTop + intrinsicHeight
|
||||
|
||||
// draw delete icon
|
||||
starIcon?.setBounds(deleteIconLeft, deleteIconTop, deleteIconRight, deleteIconBottom)
|
||||
starIcon?.draw(c)
|
||||
|
||||
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
|
||||
}
|
||||
|
||||
private fun clearCanvas(c: Canvas?, left: Float, top: Float, right: Float, bottom: Float) {
|
||||
c?.drawRect(left, top, right, bottom, clearPaint)
|
||||
}
|
||||
|
||||
// conversion from dp to px
|
||||
private fun dpToPx(context: Context): Float {
|
||||
val density = context.resources.displayMetrics.density
|
||||
return 24 * density
|
||||
}
|
||||
}
|
||||
/*
|
||||
* End of inner class
|
||||
*/
|
||||
|
||||
|
||||
}
|
||||
142
app/src/main/java/com/michatec/radio/helpers/UpdateHelper.kt
Normal file
142
app/src/main/java/com/michatec/radio/helpers/UpdateHelper.kt
Normal file
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* UpdateHelper.kt
|
||||
* Implements the UpdateHelper class
|
||||
* A UpdateHelper provides methods to update a single station or the whole collection of stations
|
||||
*
|
||||
* 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.content.Context
|
||||
import android.util.Log
|
||||
import com.michatec.radio.Keys
|
||||
import com.michatec.radio.core.Collection
|
||||
import com.michatec.radio.core.Station
|
||||
import com.michatec.radio.search.RadioBrowserResult
|
||||
import com.michatec.radio.search.RadioBrowserSearch
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
|
||||
|
||||
/*
|
||||
* UpdateHelper class
|
||||
*/
|
||||
class UpdateHelper(
|
||||
private val context: Context,
|
||||
private val updateHelperListener: UpdateHelperListener,
|
||||
private var collection: Collection
|
||||
) : RadioBrowserSearch.RadioBrowserSearchListener {
|
||||
|
||||
|
||||
/* Define log tag */
|
||||
private val TAG: String = UpdateHelper::class.java.simpleName
|
||||
|
||||
|
||||
/* Main class variables */
|
||||
private var radioBrowserSearchCounter: Int = 0
|
||||
private var remoteStationLocationsList: MutableList<String> = mutableListOf()
|
||||
|
||||
|
||||
/* Listener Interface */
|
||||
interface UpdateHelperListener {
|
||||
fun onStationUpdated(
|
||||
collection: Collection,
|
||||
positionPriorUpdate: Int,
|
||||
positionAfterUpdate: Int
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/* Overrides onRadioBrowserSearchResults from RadioBrowserSearchListener */
|
||||
override fun onRadioBrowserSearchResults(results: Array<RadioBrowserResult>) {
|
||||
if (results.isNotEmpty()) {
|
||||
CoroutineScope(IO).launch {
|
||||
// get station from results
|
||||
val station: Station = results[0].toStation()
|
||||
// detect content type
|
||||
val deferred: Deferred<NetworkHelper.ContentType> =
|
||||
async(Dispatchers.Default) { NetworkHelper.detectContentTypeSuspended(station.getStreamUri()) }
|
||||
// wait for result
|
||||
val contentType: NetworkHelper.ContentType = deferred.await()
|
||||
// update content type
|
||||
station.streamContent = contentType.type
|
||||
// get position
|
||||
val positionPriorUpdate =
|
||||
CollectionHelper.getStationPositionFromRadioBrowserStationUuid(
|
||||
collection,
|
||||
station.radioBrowserStationUuid
|
||||
)
|
||||
// update (and sort) collection
|
||||
collection = CollectionHelper.updateStation(context, collection, station)
|
||||
// get new position
|
||||
val positionAfterUpdate: Int =
|
||||
CollectionHelper.getStationPositionFromRadioBrowserStationUuid(
|
||||
collection,
|
||||
station.radioBrowserStationUuid
|
||||
)
|
||||
// hand over results
|
||||
withContext(Main) {
|
||||
updateHelperListener.onStationUpdated(
|
||||
collection,
|
||||
positionPriorUpdate,
|
||||
positionAfterUpdate
|
||||
)
|
||||
}
|
||||
// decrease counter
|
||||
radioBrowserSearchCounter--
|
||||
// all downloads from radio browser succeeded
|
||||
if (radioBrowserSearchCounter == 0 && remoteStationLocationsList.isNotEmpty()) {
|
||||
// direct download of playlists
|
||||
DownloadHelper.downloadPlaylists(
|
||||
context,
|
||||
remoteStationLocationsList.toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Updates the whole collection of stations */
|
||||
fun updateCollection() {
|
||||
PreferencesHelper.saveLastUpdateCollection()
|
||||
collection.stations.forEach { station ->
|
||||
when {
|
||||
station.radioBrowserStationUuid.isNotEmpty() -> {
|
||||
// increase counter
|
||||
radioBrowserSearchCounter++
|
||||
// request download from radio browser
|
||||
downloadFromRadioBrowser(station.radioBrowserStationUuid)
|
||||
}
|
||||
station.remoteStationLocation.isNotEmpty() -> {
|
||||
// add playlist link to list for later(!) download in onRadioBrowserSearchResults
|
||||
remoteStationLocationsList.add(station.remoteStationLocation)
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unable to update station: ${station.name}.")
|
||||
}
|
||||
}
|
||||
}
|
||||
// special case: collection contained only playlist files
|
||||
if (radioBrowserSearchCounter == 0) {
|
||||
// direct download of playlists
|
||||
DownloadHelper.downloadPlaylists(context, remoteStationLocationsList.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Get updated station from radio browser - results are handled by onRadioBrowserSearchResults */
|
||||
private fun downloadFromRadioBrowser(radioBrowserStationUuid: String) {
|
||||
val radioBrowserSearch = RadioBrowserSearch(this)
|
||||
radioBrowserSearch.searchStation(context, radioBrowserStationUuid, Keys.SEARCH_TYPE_BY_UUID)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user