Compare commits

...

4 Commits

Author SHA1 Message Date
cottongin
5929b86ee2 chore: version bump 2026-04-27 22:28:50 -04:00
cottongin
fd740929c1 feat: add station album art management
Add ability to set, edit, clear cache, delete, and generate station
artwork. Users can pick images from gallery, paste URLs, or generate
initials-based avatars. Actions accessible from both the station context
menu and the edit dialog.

Made-with: Cursor
2026-04-27 22:25:18 -04:00
cottongin
ade0323eb8 chore: version bump 2026-04-27 21:50:08 -04:00
cottongin
54aa8ad43a feat: add Shoutcast v1 stream support via raw socket fallback
Shoutcast v1 servers respond with `ICY 200 OK` instead of valid HTTP,
which OkHttp rejects. StreamConnection now falls back to a raw socket
that manually parses ICY headers when OkHttp fails with a protocol error.
No downstream changes needed — IcyParser and the audio pipeline already
handle the identical ICY metadata format.

Made-with: Cursor
2026-04-27 21:47:54 -04:00
13 changed files with 812 additions and 29 deletions

View File

@@ -18,7 +18,7 @@ android {
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "1.1"
versionName = "1.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -1,11 +1,15 @@
package xyz.cottongin.radio247.audio
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import java.io.BufferedInputStream
import java.io.IOException
import java.io.InputStream
import java.net.Socket
import java.net.URL
import java.time.Duration
class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
@@ -31,15 +35,28 @@ class StreamConnection(private val url: String) {
var streamInfo: StreamInfo? = null
private set
private var response: Response? = null
private var rawSocket: Socket? = null
fun open() {
try {
openWithOkHttp()
} catch (e: IOException) {
if (looksLikeIcyProtocolError(e)) {
Log.i(TAG, "OkHttp rejected response (likely ICY protocol), falling back to raw socket")
openWithRawSocket()
} else {
throw ConnectionFailed("Network error", e)
}
}
}
private fun openWithOkHttp() {
val request = Request.Builder()
.url(url)
.header("Icy-MetaData", "1")
.header("User-Agent", "Radio247/1.0")
.build()
try {
val resp = client.newCall(request).execute()
if (!resp.isSuccessful) {
resp.close()
@@ -54,8 +71,45 @@ class StreamConnection(private val url: String) {
)
inputStream = resp.body?.byteStream()
?: throw ConnectionFailed("Empty response body")
}
private fun openWithRawSocket() {
try {
val parsed = URL(url)
val host = parsed.host ?: throw ConnectionFailed("No host in URL")
val port = if (parsed.port != -1) parsed.port else parsed.defaultPort
val path = if (parsed.path.isNullOrEmpty()) "/" else parsed.path
val socket = Socket(host, port)
socket.soTimeout = 30_000
rawSocket = socket
val out = socket.getOutputStream()
val requestStr = "GET $path HTTP/1.0\r\n" +
"Host: $host\r\n" +
"User-Agent: Radio247/1.0\r\n" +
"Icy-MetaData: 1\r\n" +
"Accept: */*\r\n" +
"\r\n"
out.write(requestStr.toByteArray(Charsets.US_ASCII))
out.flush()
val buffered = BufferedInputStream(socket.getInputStream(), 16384)
val headers = parseIcyHeaders(buffered)
metaint = headers["icy-metaint"]?.toIntOrNull()
streamInfo = StreamInfo(
bitrate = headers["icy-br"]?.toIntOrNull(),
ssl = url.startsWith("https", ignoreCase = true),
contentType = headers["content-type"]
)
inputStream = buffered
} catch (e: ConnectionFailed) {
closeRawSocket()
throw e
} catch (e: IOException) {
throw ConnectionFailed("Network error", e)
closeRawSocket()
throw ConnectionFailed("Network error (raw socket)", e)
}
}
@@ -66,9 +120,74 @@ class StreamConnection(private val url: String) {
try {
response?.close()
} catch (_: IOException) {}
closeRawSocket()
response = null
inputStream = null
metaint = null
streamInfo = null
}
private fun closeRawSocket() {
try {
rawSocket?.close()
} catch (_: IOException) {}
rawSocket = null
}
companion object {
private const val TAG = "StreamConnection"
/**
* Shoutcast v1 servers respond with `ICY 200 OK` instead of a valid HTTP
* status line. OkHttp surfaces this as a ProtocolException.
*/
internal fun looksLikeIcyProtocolError(e: IOException): Boolean {
val msg = e.message ?: return false
return msg.contains("unexpected status line", ignoreCase = true) ||
msg.contains("http/0.9", ignoreCase = true) ||
e.javaClass.simpleName == "ProtocolException"
}
/**
* Reads headers line-by-line from an ICY response until the blank line.
* Returns a map of lowercased header names to values. Throws
* [ConnectionFailed] if the status line is not `ICY 200 OK` or `HTTP/1.x 200`.
*/
internal fun parseIcyHeaders(input: InputStream): Map<String, String> {
val statusLine = readLine(input)
if (!statusLine.startsWith("ICY ", ignoreCase = true) &&
!statusLine.startsWith("HTTP/", ignoreCase = true)
) {
throw ConnectionFailed("Unexpected status line: $statusLine")
}
val headers = mutableMapOf<String, String>()
while (true) {
val line = readLine(input)
if (line.isEmpty()) break
val colon = line.indexOf(':')
if (colon > 0) {
headers[line.substring(0, colon).trim().lowercase()] =
line.substring(colon + 1).trim()
}
}
return headers
}
private fun readLine(input: InputStream): String {
val sb = StringBuilder(128)
while (true) {
val b = input.read()
if (b == -1) break
if (b == '\n'.code) break
if (b == '\r'.code) {
input.mark(1)
if (input.read() != '\n'.code) input.reset()
break
}
sb.append(b.toChar())
}
return sb.toString()
}
}
}

View File

@@ -54,4 +54,7 @@ interface StationDao {
@Query("UPDATE stations SET listenerCount = :count WHERE id = :id")
suspend fun updateListenerCount(id: Long, count: Int)
@Query("UPDATE stations SET defaultArtworkUrl = :artworkUrl WHERE id = :id")
suspend fun updateArtworkUrl(id: Long, artworkUrl: String?)
}

View File

@@ -11,7 +11,7 @@ import java.net.URLEncoder
class AlbumArtResolver(
private val client: OkHttpClient,
private val artCache: ArtCache = ArtCache(),
val artCache: ArtCache = ArtCache(),
private val musicBrainzBaseUrl: String = "https://musicbrainz.org",
private val coverArtBaseUrl: String = "https://coverartarchive.org",
private val skipArtVerification: Boolean = false,

View File

@@ -14,4 +14,14 @@ class ArtCache(private val maxSize: Int = 500) {
fun put(key: String, url: String) {
cache[key] = url
}
@Synchronized
fun removeByValue(url: String) {
cache.entries.removeAll { it.value == url }
}
@Synchronized
fun clear() {
cache.clear()
}
}

View File

@@ -1,10 +1,23 @@
package xyz.cottongin.radio247.ui.screens.stationlist
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -13,23 +26,98 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import xyz.cottongin.radio247.R
@Composable
fun AddStationDialog(
onDismiss: () -> Unit,
onConfirm: (name: String, url: String) -> Unit,
onConfirm: (name: String, url: String, artworkUrl: String?) -> Unit,
onPickImage: (onResult: (String) -> Unit) -> Unit,
modifier: Modifier = Modifier
) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var artworkUrl by remember { mutableStateOf<String?>(null) }
var showUrlField by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add Station") },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape),
contentAlignment = Alignment.Center
) {
if (artworkUrl != null) {
AsyncImage(
model = artworkUrl,
contentDescription = "Station artwork",
modifier = Modifier.size(80.dp).clip(CircleShape)
)
} else {
Icon(
painter = painterResource(R.drawable.ic_radio_placeholder),
contentDescription = "No artwork",
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { onPickImage { picked -> artworkUrl = picked } },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Image, contentDescription = "Pick image", modifier = Modifier.size(20.dp))
}
IconButton(
onClick = { showUrlField = !showUrlField },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Link, contentDescription = "Set URL", modifier = Modifier.size(20.dp))
}
if (artworkUrl != null) {
IconButton(
onClick = { artworkUrl = null },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Close, contentDescription = "Remove artwork", modifier = Modifier.size(20.dp))
}
}
}
if (showUrlField) {
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = artworkUrl ?: "",
onValueChange = { artworkUrl = it.ifBlank { null } },
label = { Text("Artwork URL") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
@@ -51,7 +139,7 @@ fun AddStationDialog(
TextButton(
onClick = {
if (name.isNotBlank() && url.isNotBlank()) {
onConfirm(name.trim(), url.trim())
onConfirm(name.trim(), url.trim(), artworkUrl?.trim())
onDismiss()
}
}

View File

@@ -1,10 +1,25 @@
package xyz.cottongin.radio247.ui.screens.stationlist
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoFixHigh
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -13,25 +28,109 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import xyz.cottongin.radio247.R
import xyz.cottongin.radio247.data.model.Station
@Composable
fun EditStationDialog(
station: Station,
onDismiss: () -> Unit,
onConfirm: (name: String, url: String) -> Unit,
onConfirm: (name: String, url: String, artworkUrl: String?) -> Unit,
onPickImage: () -> Unit,
onGenerateArt: () -> Unit,
onClearCache: () -> Unit,
onDeleteArt: () -> Unit,
modifier: Modifier = Modifier
) {
var name by remember(station.id) { mutableStateOf(station.name) }
var url by remember(station.id) { mutableStateOf(station.url) }
var artworkUrl by remember(station.id) { mutableStateOf(station.defaultArtworkUrl) }
var showUrlField by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Edit Station") },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape),
contentAlignment = Alignment.Center
) {
if (artworkUrl != null) {
AsyncImage(
model = artworkUrl,
contentDescription = "Station artwork",
modifier = Modifier.size(80.dp).clip(CircleShape)
)
} else {
Icon(
painter = painterResource(R.drawable.ic_radio_placeholder),
contentDescription = "No artwork",
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onPickImage, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Image, contentDescription = "Pick image", modifier = Modifier.size(20.dp))
}
IconButton(
onClick = { showUrlField = !showUrlField },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Link, contentDescription = "Set URL", modifier = Modifier.size(20.dp))
}
IconButton(onClick = onGenerateArt, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.AutoFixHigh, contentDescription = "Generate art", modifier = Modifier.size(20.dp))
}
if (artworkUrl != null) {
IconButton(onClick = onClearCache, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Refresh, contentDescription = "Clear cache", modifier = Modifier.size(20.dp))
}
IconButton(
onClick = {
artworkUrl = null
onDeleteArt()
},
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Close, contentDescription = "Remove artwork", modifier = Modifier.size(20.dp))
}
}
}
if (showUrlField) {
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = artworkUrl ?: "",
onValueChange = { artworkUrl = it.ifBlank { null } },
label = { Text("Artwork URL") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
@@ -53,7 +152,7 @@ fun EditStationDialog(
TextButton(
onClick = {
if (name.isNotBlank() && url.isNotBlank()) {
onConfirm(name.trim(), url.trim())
onConfirm(name.trim(), url.trim(), artworkUrl?.trim())
onDismiss()
}
}

View File

@@ -107,6 +107,8 @@ fun StationListScreen(
var qualityStreams by remember { mutableStateOf<List<StationStream>>(emptyList()) }
var qualityCurrentOrder by remember { mutableStateOf<List<String>>(emptyList()) }
var tabToRename by remember { mutableStateOf<TabInfo?>(null) }
var artworkPickTarget by remember { mutableStateOf<Station?>(null) }
var addDialogArtCallback by remember { mutableStateOf<((String) -> Unit)?>(null) }
val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
@@ -115,6 +117,29 @@ fun StationListScreen(
}
)
val artworkPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
onResult = { uri: Uri? ->
if (uri != null) {
val target = artworkPickTarget
val addCallback = addDialogArtCallback
when {
target != null -> {
viewModel.copyImageToInternal(target.id, uri)
artworkPickTarget = null
}
addCallback != null -> {
addCallback(uri.toString())
addDialogArtCallback = null
}
}
} else {
artworkPickTarget = null
addDialogArtCallback = null
}
}
)
val currentTab = viewState.tabs.getOrNull(viewState.selectedTabIndex)
val isBuiltInTab = currentTab?.isBuiltIn == true
@@ -249,6 +274,13 @@ fun StationListScreen(
onEdit = { stationToEdit = station },
onDelete = { viewModel.deleteStation(station) },
onToggleHidden = { viewModel.toggleHidden(station) },
onSetArtwork = {
artworkPickTarget = station
artworkPickerLauncher.launch("image/*")
},
onClearArtCache = { viewModel.clearStationArtworkCache(station) },
onRemoveArtwork = { viewModel.deleteStationArtwork(station.id) },
onGenerateArt = { viewModel.generateStationArtwork(station) },
onQuality = {
viewModel.getStreamsForStation(station.id) { streams ->
viewModel.getQualityOverrideForStation(station.id) { json ->
@@ -282,9 +314,13 @@ fun StationListScreen(
if (showAddStation) {
AddStationDialog(
onDismiss = { showAddStation = false },
onConfirm = { name, url ->
viewModel.addStation(name, url)
onConfirm = { name, url, artworkUrl ->
viewModel.addStation(name, url, artworkUrl)
showAddStation = false
},
onPickImage = { callback ->
addDialogArtCallback = callback
artworkPickerLauncher.launch("image/*")
}
)
}
@@ -303,10 +339,17 @@ fun StationListScreen(
EditStationDialog(
station = station,
onDismiss = { stationToEdit = null },
onConfirm = { name, url ->
viewModel.updateStation(station, name, url)
onConfirm = { name, url, artworkUrl ->
viewModel.updateStation(station, name, url, artworkUrl)
stationToEdit = null
}
},
onPickImage = {
artworkPickTarget = station
artworkPickerLauncher.launch("image/*")
},
onGenerateArt = { viewModel.generateStationArtwork(station) },
onClearCache = { viewModel.clearStationArtworkCache(station) },
onDeleteArt = { viewModel.deleteStationArtwork(station.id) }
)
}
@@ -597,6 +640,10 @@ private fun StationRow(
onDelete: () -> Unit,
onToggleHidden: () -> Unit,
onQuality: () -> Unit,
onSetArtwork: () -> Unit,
onClearArtCache: () -> Unit,
onRemoveArtwork: () -> Unit,
onGenerateArt: () -> Unit,
modifier: Modifier = Modifier
) {
var showMenu by remember { mutableStateOf(false) }
@@ -716,6 +763,38 @@ private fun StationRow(
}
)
}
if (!isHiddenView) {
DropdownMenuItem(
text = { Text("Set Artwork") },
onClick = {
showMenu = false
onSetArtwork()
}
)
DropdownMenuItem(
text = { Text("Generate Artwork") },
onClick = {
showMenu = false
onGenerateArt()
}
)
if (station.defaultArtworkUrl != null) {
DropdownMenuItem(
text = { Text("Clear Art Cache") },
onClick = {
showMenu = false
onClearArtCache()
}
)
DropdownMenuItem(
text = { Text("Remove Artwork") },
onClick = {
showMenu = false
onRemoveArtwork()
}
)
}
}
}
}
}

View File

@@ -1,6 +1,8 @@
package xyz.cottongin.radio247.ui.screens.stationlist
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@@ -13,6 +15,7 @@ import xyz.cottongin.radio247.data.model.Playlist
import xyz.cottongin.radio247.data.model.Station
import xyz.cottongin.radio247.data.model.StationPreference
import xyz.cottongin.radio247.data.model.StationStream
import xyz.cottongin.radio247.util.ArtworkGenerator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
@@ -26,6 +29,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
data class TabInfo(
val playlist: Playlist?,
@@ -250,17 +254,55 @@ class StationListViewModel(application: Application) : AndroidViewModel(applicat
viewModelScope.launch { stationDao.delete(station) }
}
fun addStation(name: String, url: String) {
fun addStation(name: String, url: String, artworkUrl: String? = null) {
val currentTab = viewState.value.tabs.getOrNull(viewState.value.selectedTabIndex)
val playlistId = currentTab?.playlist?.id
viewModelScope.launch {
stationDao.insert(Station(name = name, url = url, playlistId = playlistId))
stationDao.insert(Station(name = name, url = url, playlistId = playlistId, defaultArtworkUrl = artworkUrl))
}
}
fun updateStation(station: Station, name: String, url: String) {
fun updateStation(station: Station, name: String, url: String, artworkUrl: String? = station.defaultArtworkUrl) {
viewModelScope.launch {
stationDao.update(station.copy(name = name, url = url))
stationDao.update(station.copy(name = name, url = url, defaultArtworkUrl = artworkUrl))
}
}
fun updateStationArtwork(stationId: Long, artworkUrl: String?) {
viewModelScope.launch {
stationDao.updateArtworkUrl(stationId, artworkUrl)
}
}
fun deleteStationArtwork(stationId: Long) {
updateStationArtwork(stationId, null)
}
fun clearStationArtworkCache(station: Station) {
val url = station.defaultArtworkUrl ?: return
app.albumArtResolver.artCache.removeByValue(url)
}
fun generateStationArtwork(station: Station) {
viewModelScope.launch(Dispatchers.IO) {
val path = ArtworkGenerator.generateAndSave(app, station.id, station.name)
stationDao.updateArtworkUrl(station.id, path)
}
}
fun copyImageToInternal(stationId: Long, sourceUri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
val dir = File(app.filesDir, "station_art")
dir.mkdirs()
val destFile = File(dir, "$stationId.png")
app.contentResolver.openInputStream(sourceUri)?.use { input ->
val bitmap = BitmapFactory.decodeStream(input) ?: return@launch
destFile.outputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
bitmap.recycle()
} ?: return@launch
stationDao.updateArtworkUrl(stationId, destFile.toURI().toString())
}
}

View File

@@ -0,0 +1,77 @@
package xyz.cottongin.radio247.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import java.io.File
import kotlin.math.abs
object ArtworkGenerator {
private val PALETTE = intArrayOf(
0xFF1E88E5.toInt(), // Blue
0xFF43A047.toInt(), // Green
0xFFE53935.toInt(), // Red
0xFF8E24AA.toInt(), // Purple
0xFFFB8C00.toInt(), // Orange
0xFF00ACC1.toInt(), // Cyan
0xFF3949AB.toInt(), // Indigo
0xFF7CB342.toInt(), // Light Green
0xFFD81B60.toInt(), // Pink
0xFF5E35B1.toInt(), // Deep Purple
0xFF039BE5.toInt(), // Light Blue
0xFFC0CA33.toInt(), // Lime
)
fun extractInitials(name: String): String {
val words = name.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }
return when {
words.size >= 2 -> "${words[0].first().uppercaseChar()}${words[1].first().uppercaseChar()}"
words.size == 1 && words[0].length >= 2 -> words[0].take(2).uppercase()
words.size == 1 -> words[0].first().uppercaseChar().toString()
else -> "?"
}
}
fun pickColor(name: String): Int {
return PALETTE[abs(name.hashCode()) % PALETTE.size]
}
fun generateBitmap(name: String, sizePx: Int = 256): Bitmap {
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = pickColor(name)
style = Paint.Style.FILL
}
val cx = sizePx / 2f
val cy = sizePx / 2f
canvas.drawCircle(cx, cy, cx, bgPaint)
val initials = extractInitials(name)
val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = sizePx * 0.38f
typeface = Typeface.DEFAULT_BOLD
textAlign = Paint.Align.CENTER
}
val textY = cy - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(initials, cx, textY, textPaint)
return bitmap
}
fun generateAndSave(context: Context, stationId: Long, stationName: String): String {
val dir = File(context.filesDir, "station_art")
dir.mkdirs()
val file = File(dir, "$stationId.png")
val bitmap = generateBitmap(stationName)
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
bitmap.recycle()
return file.toURI().toString()
}
}

View File

@@ -9,6 +9,8 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import java.io.ByteArrayInputStream
import java.net.ProtocolException
class StreamConnectionTest {
@@ -120,4 +122,105 @@ class StreamConnectionTest {
conn.close()
}
// --- ICY protocol header parsing (Shoutcast v1 support) ---
@Test
fun parseIcyHeaders_extracts_standard_shoutcast_headers() {
val raw = "ICY 200 OK\r\n" +
"icy-notice1:<BR>This stream requires Winamp<BR>\r\n" +
"icy-name:My Station\r\n" +
"icy-genre:Rock\r\n" +
"content-type:audio/mpeg\r\n" +
"icy-pub:1\r\n" +
"icy-metaint:8192\r\n" +
"icy-br:128\r\n" +
"\r\n" +
"audio bytes here"
val input = ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
val headers = StreamConnection.parseIcyHeaders(input)
assertEquals("8192", headers["icy-metaint"])
assertEquals("128", headers["icy-br"])
assertEquals("audio/mpeg", headers["content-type"])
assertEquals("My Station", headers["icy-name"])
assertEquals("Rock", headers["icy-genre"])
val remaining = input.readBytes().toString(Charsets.US_ASCII)
assertEquals("audio bytes here", remaining)
}
@Test
fun parseIcyHeaders_accepts_http_status_line() {
val raw = "HTTP/1.0 200 OK\r\n" +
"icy-metaint:16000\r\n" +
"Content-Type:audio/mpeg\r\n" +
"\r\n"
val headers = StreamConnection.parseIcyHeaders(
ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
)
assertEquals("16000", headers["icy-metaint"])
assertEquals("audio/mpeg", headers["content-type"])
}
@Test(expected = ConnectionFailed::class)
fun parseIcyHeaders_rejects_garbage_status_line() {
val raw = "GARBAGE\r\n\r\n"
StreamConnection.parseIcyHeaders(
ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
)
}
@Test
fun parseIcyHeaders_handles_missing_metaint() {
val raw = "ICY 200 OK\r\n" +
"content-type:audio/mpeg\r\n" +
"icy-br:64\r\n" +
"\r\n"
val headers = StreamConnection.parseIcyHeaders(
ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
)
assertNull(headers["icy-metaint"])
assertEquals("64", headers["icy-br"])
}
@Test
fun parseIcyHeaders_handles_lf_only_line_endings() {
val raw = "ICY 200 OK\n" +
"icy-metaint:4096\n" +
"content-type:audio/mpeg\n" +
"\n"
val headers = StreamConnection.parseIcyHeaders(
ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
)
assertEquals("4096", headers["icy-metaint"])
}
@Test
fun looksLikeIcyProtocolError_matches_protocol_exception() {
assertTrue(
StreamConnection.looksLikeIcyProtocolError(
ProtocolException("unexpected status line: ICY 200 OK")
)
)
}
@Test
fun looksLikeIcyProtocolError_matches_http09_message() {
assertTrue(
StreamConnection.looksLikeIcyProtocolError(
java.io.IOException("Received HTTP/0.9 when not allowed")
)
)
}
@Test
fun looksLikeIcyProtocolError_does_not_match_unrelated_io_error() {
assertTrue(
!StreamConnection.looksLikeIcyProtocolError(
java.io.IOException("Connection reset by peer")
)
)
}
}

View File

@@ -0,0 +1,75 @@
package xyz.cottongin.radio247.metadata
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ArtCacheTest {
@Test
fun get_returnsNullForMissingKey() {
val cache = ArtCache()
assertNull(cache.get("nonexistent"))
}
@Test
fun put_and_get() {
val cache = ArtCache()
cache.put("key1", "https://example.com/art.jpg")
assertEquals("https://example.com/art.jpg", cache.get("key1"))
}
@Test
fun evicts_when_exceeding_maxSize() {
val cache = ArtCache(maxSize = 2)
cache.put("a", "url-a")
cache.put("b", "url-b")
cache.put("c", "url-c")
assertNull(cache.get("a"))
assertEquals("url-b", cache.get("b"))
assertEquals("url-c", cache.get("c"))
}
@Test
fun removeByValue_removesMatchingEntries() {
val cache = ArtCache()
cache.put("key1", "https://station.com/art.png")
cache.put("key2", "https://other.com/art.png")
cache.put("key3", "https://station.com/art.png")
cache.removeByValue("https://station.com/art.png")
assertNull(cache.get("key1"))
assertEquals("https://other.com/art.png", cache.get("key2"))
assertNull(cache.get("key3"))
}
@Test
fun removeByValue_noOpWhenValueNotPresent() {
val cache = ArtCache()
cache.put("key1", "url-a")
cache.removeByValue("url-b")
assertEquals("url-a", cache.get("key1"))
}
@Test
fun clear_removesAllEntries() {
val cache = ArtCache()
cache.put("a", "url-a")
cache.put("b", "url-b")
cache.put("c", "url-c")
cache.clear()
assertNull(cache.get("a"))
assertNull(cache.get("b"))
assertNull(cache.get("c"))
}
@Test
fun clear_onEmptyCacheIsNoOp() {
val cache = ArtCache()
cache.clear()
assertNull(cache.get("anything"))
}
}

View File

@@ -0,0 +1,88 @@
package xyz.cottongin.radio247.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ArtworkGeneratorTest {
@Test
fun extractInitials_twoWords() {
assertEquals("GS", ArtworkGenerator.extractInitials("Groove Salad"))
}
@Test
fun extractInitials_threeWords() {
assertEquals("DR", ArtworkGenerator.extractInitials("Drone Radio Station"))
}
@Test
fun extractInitials_singleLongWord() {
assertEquals("SO", ArtworkGenerator.extractInitials("SomaFM"))
}
@Test
fun extractInitials_singleChar() {
assertEquals("X", ArtworkGenerator.extractInitials("X"))
}
@Test
fun extractInitials_blank() {
assertEquals("?", ArtworkGenerator.extractInitials(""))
}
@Test
fun extractInitials_whitespace() {
assertEquals("?", ArtworkGenerator.extractInitials(" "))
}
@Test
fun extractInitials_leadingTrailingSpaces() {
assertEquals("GS", ArtworkGenerator.extractInitials(" Groove Salad "))
}
@Test
fun extractInitials_lowercaseInput() {
assertEquals("GS", ArtworkGenerator.extractInitials("groove salad"))
}
@Test
fun pickColor_deterministic() {
val c1 = ArtworkGenerator.pickColor("Groove Salad")
val c2 = ArtworkGenerator.pickColor("Groove Salad")
assertEquals(c1, c2)
}
@Test
fun pickColor_differentNamesCanDiffer() {
val c1 = ArtworkGenerator.pickColor("Groove Salad")
val c2 = ArtworkGenerator.pickColor("Drone Zone")
// Not guaranteed to differ (hash collisions) but the palette is broad enough
// that two very different strings are likely to map to different colors.
// This is a soft assertion -- we mainly verify no crash and determinism.
assertTrue(c1 != 0)
assertTrue(c2 != 0)
}
@Test
fun pickColor_alwaysFromPalette() {
val palette = intArrayOf(
0xFF1E88E5.toInt(), 0xFF43A047.toInt(), 0xFFE53935.toInt(),
0xFF8E24AA.toInt(), 0xFFFB8C00.toInt(), 0xFF00ACC1.toInt(),
0xFF3949AB.toInt(), 0xFF7CB342.toInt(), 0xFFD81B60.toInt(),
0xFF5E35B1.toInt(), 0xFF039BE5.toInt(), 0xFFC0CA33.toInt(),
)
val names = listOf(
"Alpha", "Bravo", "Charlie", "Delta", "Echo",
"Foxtrot", "Golf", "Hotel", "India", "Juliet",
"Kilo", "Lima", "Mike", "November", "Oscar"
)
for (name in names) {
assertTrue(
"Color for '$name' should be in palette",
ArtworkGenerator.pickColor(name) in palette
)
}
}
}