Initial commit

This commit is contained in:
Michatec
2025-04-27 15:07:05 +02:00
commit 2162c9fb40
157 changed files with 12179 additions and 0 deletions

View File

@@ -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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View File

@@ -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)
)
}
}

View 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
}
}

View 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")
}
}

View 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
}
}

View 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)
}
}

View 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
}
}

View File

@@ -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
)
}
}

View File

@@ -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
)
)
}
}
}

View 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
*/
}

View 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)
}
}