Covers project scaffolding, Room data layer, PLS/M3U import/export, audio engine stages (IcyParser, Mp3FrameSync, StreamConnection), engine integration, foreground service with Stay Connected, Compose UI (station list, now playing, settings), album art resolution with MusicBrainz fallback chain, and final integration. Made-with: Cursor
54 KiB
Android 24/7 Radio Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build a personal-use Android app for 24/7 internet radio streaming with a custom raw audio pipeline for absolute minimum latency, aggressive reconnection, and Icecast/Shoutcast metadata support.
Architecture: Custom raw audio pipeline (OkHttp → IcyParser → Mp3FrameSync → MediaCodec → AudioTrack) wrapped in a foreground service with aggressive reconnection. Room DB for persistence, Jetpack Compose for UI. See docs/plans/2026-03-09-android-247-radio-design.md for full design.
Tech Stack: Kotlin, Jetpack Compose (Material 3), Room, DataStore, OkHttp, MediaCodec, AudioTrack, MediaSession
Package: xyz.cottongin.radio247
Task 1: Project Scaffolding
Set up the Gradle Android project with all dependencies and basic structure.
Files:
- Create:
settings.gradle.kts - Create:
build.gradle.kts(root) - Create:
app/build.gradle.kts - Create:
gradle.properties - Create:
gradle/libs.versions.toml - Create:
app/src/main/AndroidManifest.xml - Create:
app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt - Create:
app/src/main/java/xyz/cottongin/radio247/MainActivity.kt - Create:
app/src/main/res/values/strings.xml - Create:
app/src/main/res/values/themes.xml - Create:
app/src/main/res/drawable/ic_radio_placeholder.xml(vector drawable placeholder)
Step 1: Create Gradle wrapper and project files
Use the latest stable AGP and Kotlin. Version catalog in gradle/libs.versions.toml:
[versions]
agp = "8.7.3"
kotlin = "2.1.0"
compose-bom = "2025.02.00"
room = "2.7.1"
datastore = "1.1.4"
okhttp = "4.12.0"
lifecycle = "2.9.0"
coroutines = "1.10.1"
ksp = "2.1.0-1.0.29"
media = "1.5.1"
junit = "4.13.2"
mockk = "1.13.16"
turbine = "1.2.0"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-activity = { group = "androidx.activity", name = "activity-compose", version = "1.10.1" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" }
coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
media-session = { group = "androidx.media", name = "media", version.ref = "media" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Root build.gradle.kts:
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
}
settings.gradle.kts:
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Android-247-Radio"
include(":app")
app/build.gradle.kts:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
android {
namespace = "xyz.cottongin.radio247"
compileSdk = 35
defaultConfig {
applicationId = "xyz.cottongin.radio247"
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.activity)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.datastore.preferences)
implementation(libs.okhttp)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.service)
implementation(libs.coroutines.core)
implementation(libs.coroutines.android)
implementation(libs.media.session)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.coroutines.test)
testImplementation(libs.turbine)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.room.testing)
}
Step 2: Create AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".RadioApplication"
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Radio247">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Radio247">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.RadioPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>
Step 3: Create stub Application, Activity, resource files
RadioApplication.kt — empty Application subclass (will hold DB singleton later).
MainActivity.kt — minimal ComponentActivity with setContent {} showing a placeholder Text composable.
strings.xml — app_name = "24/7 Radio".
themes.xml — empty Material3 theme stub.
Step 4: Install Gradle wrapper
Run: gradle wrapper --gradle-version 8.12 (or download wrapper files manually).
Step 5: Verify build
Run: ./gradlew assembleDebug
Expected: BUILD SUCCESSFUL
Step 6: Commit
git add -A
git commit -m "feat: scaffold Android project with dependencies"
Task 2: Data Layer — Room Entities and DAOs
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/data/model/Station.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/model/Playlist.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/model/MetadataSnapshot.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/model/ListeningSession.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/model/ConnectionSpan.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/db/StationDao.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/db/PlaylistDao.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/db/MetadataSnapshotDao.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/db/ListeningSessionDao.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/db/ConnectionSpanDao.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/db/RadioDatabase.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/prefs/RadioPreferences.kt - Test:
app/src/test/java/xyz/cottongin/radio247/data/model/(entity unit tests if any logic)
Step 1: Write Room entities
Station.kt:
@Entity(
tableName = "stations",
foreignKeys = [ForeignKey(
entity = Playlist::class,
parentColumns = ["id"],
childColumns = ["playlistId"],
onDelete = ForeignKey.SET_NULL
)],
indices = [Index("playlistId")]
)
data class Station(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val url: String,
val playlistId: Long? = null,
val sortOrder: Int = 0,
val starred: Boolean = false,
val defaultArtworkUrl: String? = null
)
Playlist.kt:
@Entity(tableName = "playlists")
data class Playlist(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
val sortOrder: Int = 0,
val starred: Boolean = false
)
MetadataSnapshot.kt:
@Entity(
tableName = "metadata_snapshots",
foreignKeys = [ForeignKey(
entity = Station::class,
parentColumns = ["id"],
childColumns = ["stationId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("stationId"), Index("timestamp")]
)
data class MetadataSnapshot(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val stationId: Long,
val title: String? = null,
val artist: String? = null,
val artworkUrl: String? = null,
val timestamp: Long
)
ListeningSession.kt:
@Entity(
tableName = "listening_sessions",
foreignKeys = [ForeignKey(
entity = Station::class,
parentColumns = ["id"],
childColumns = ["stationId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("stationId")]
)
data class ListeningSession(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val stationId: Long,
val startedAt: Long,
val endedAt: Long? = null
)
ConnectionSpan.kt:
@Entity(
tableName = "connection_spans",
foreignKeys = [ForeignKey(
entity = ListeningSession::class,
parentColumns = ["id"],
childColumns = ["sessionId"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("sessionId")]
)
data class ConnectionSpan(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val sessionId: Long,
val startedAt: Long,
val endedAt: Long? = null
)
Step 2: Write DAOs
StationDao.kt — CRUD, queries: all stations ordered by starred desc then sortOrder, stations by playlist, update sortOrder, toggle starred.
PlaylistDao.kt — CRUD, all playlists ordered by starred desc then sortOrder, update sortOrder, toggle starred.
MetadataSnapshotDao.kt — insert, query by stationId (newest first), query recent across all stations, search by artist/title.
ListeningSessionDao.kt — insert, update endedAt, query active session, query recent sessions.
ConnectionSpanDao.kt — insert, update endedAt, query by sessionId, query active span.
Key DAO patterns:
- Return
Flow<List<T>>for observable queries (station list, playlist list). - Return
suspendfunctions for writes and one-shot reads.
Step 3: Write RadioDatabase
@Database(
entities = [Station::class, Playlist::class, MetadataSnapshot::class,
ListeningSession::class, ConnectionSpan::class],
version = 1,
exportSchema = true
)
abstract class RadioDatabase : RoomDatabase() {
abstract fun stationDao(): StationDao
abstract fun playlistDao(): PlaylistDao
abstract fun metadataSnapshotDao(): MetadataSnapshotDao
abstract fun listeningSessionDao(): ListeningSessionDao
abstract fun connectionSpanDao(): ConnectionSpanDao
}
Provide the database singleton via RadioApplication (manual DI — no Hilt/Dagger for personal use, keep it simple).
Step 4: Write RadioPreferences
class RadioPreferences(private val context: Context) {
private val dataStore = context.dataStore
val stayConnected: Flow<Boolean> = dataStore.data.map { it[STAY_CONNECTED] ?: false }
val bufferMs: Flow<Int> = dataStore.data.map { it[BUFFER_MS] ?: 0 }
val lastStationId: Flow<Long?> = dataStore.data.map { it[LAST_STATION_ID] }
suspend fun setStayConnected(value: Boolean) { ... }
suspend fun setBufferMs(value: Int) { ... }
suspend fun setLastStationId(value: Long) { ... }
companion object {
private val Context.dataStore by preferencesDataStore(name = "radio_prefs")
private val STAY_CONNECTED = booleanPreferencesKey("stay_connected")
private val BUFFER_MS = intPreferencesKey("buffer_ms")
private val LAST_STATION_ID = longPreferencesKey("last_station_id")
}
}
Step 5: Verify build
Run: ./gradlew assembleDebug
Expected: BUILD SUCCESSFUL
Step 6: Commit
git add -A
git commit -m "feat: add Room entities, DAOs, database, and DataStore preferences"
Task 3: PLS/M3U Import & Export
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/data/import/M3uParser.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/import/PlsParser.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/import/PlaylistExporter.kt - Create:
app/src/main/java/xyz/cottongin/radio247/data/import/ParsedStation.kt - Test:
app/src/test/java/xyz/cottongin/radio247/data/import/M3uParserTest.kt - Test:
app/src/test/java/xyz/cottongin/radio247/data/import/PlsParserTest.kt - Test:
app/src/test/java/xyz/cottongin/radio247/data/import/PlaylistExporterTest.kt
Step 1: Define ParsedStation data class
data class ParsedStation(
val name: String,
val url: String,
val artworkUrl: String? = null
)
No Room annotations — this is a transfer object for import/export.
Step 2: Write failing M3U parser tests
Test cases:
- Parse basic M3U with
#EXTINFand URLs - Parse M3U with
#EXTIMGartwork URLs - Handle missing
#EXTINF(URL-only lines) - Handle blank lines and comments
- Handle Windows-style line endings (
\r\n)
class M3uParserTest {
@Test
fun `parse basic m3u with extinf`() {
val input = """
#EXTM3U
#EXTINF:-1,Station One
http://stream.example.com:8000/live
#EXTINF:-1,Station Two
http://other.example.com/stream
""".trimIndent()
val result = M3uParser.parse(input)
assertEquals(2, result.size)
assertEquals(ParsedStation("Station One", "http://stream.example.com:8000/live"), result[0])
assertEquals(ParsedStation("Station Two", "http://other.example.com/stream"), result[1])
}
@Test
fun `parse m3u with extimg`() {
val input = """
#EXTM3U
#EXTINF:-1,My Station
#EXTIMG:http://example.com/art.jpg
http://stream.example.com/live
""".trimIndent()
val result = M3uParser.parse(input)
assertEquals(1, result.size)
assertEquals("http://example.com/art.jpg", result[0].artworkUrl)
}
@Test
fun `parse url-only lines`() { ... }
@Test
fun `handle blank lines and comments`() { ... }
@Test
fun `handle crlf line endings`() { ... }
}
Step 3: Run tests to verify they fail
Run: ./gradlew test --tests "*.M3uParserTest" -v
Expected: FAIL — class not found
Step 4: Implement M3uParser
object M3uParser {
fun parse(content: String): List<ParsedStation> {
val stations = mutableListOf<ParsedStation>()
var currentName: String? = null
var currentArt: String? = null
for (rawLine in content.lines()) {
val line = rawLine.trim()
when {
line.isEmpty() || line == "#EXTM3U" -> continue
line.startsWith("#EXTINF:") -> {
currentName = line.substringAfter(",").trim().ifEmpty { null }
}
line.startsWith("#EXTIMG:") -> {
currentArt = line.removePrefix("#EXTIMG:").trim().ifEmpty { null }
}
line.startsWith("#") -> continue
else -> {
stations.add(ParsedStation(
name = currentName ?: line.substringAfterLast("/"),
url = line,
artworkUrl = currentArt
))
currentName = null
currentArt = null
}
}
}
return stations
}
}
Step 5: Run tests to verify they pass
Run: ./gradlew test --tests "*.M3uParserTest" -v
Expected: PASS
Step 6: Write failing PLS parser tests
Test cases:
- Parse basic PLS with numbered entries
- Handle missing titles (use URL as fallback name)
- Handle case-insensitive keys
class PlsParserTest {
@Test
fun `parse basic pls`() {
val input = """
[playlist]
NumberOfEntries=2
File1=http://stream.example.com/live
Title1=Station One
File2=http://other.example.com/stream
Title2=Station Two
""".trimIndent()
val result = PlsParser.parse(input)
assertEquals(2, result.size)
assertEquals("Station One", result[0].name)
assertEquals("http://stream.example.com/live", result[0].url)
}
// ... more test cases
}
Step 7: Run PLS tests to verify they fail
Run: ./gradlew test --tests "*.PlsParserTest" -v
Expected: FAIL
Step 8: Implement PlsParser
object PlsParser {
fun parse(content: String): List<ParsedStation> {
val files = mutableMapOf<Int, String>()
val titles = mutableMapOf<Int, String>()
for (rawLine in content.lines()) {
val line = rawLine.trim()
val lower = line.lowercase()
when {
lower.startsWith("file") -> {
val (key, value) = line.split("=", limit = 2)
val index = key.removePrefix("File").removePrefix("file").toIntOrNull() ?: continue
files[index] = value.trim()
}
lower.startsWith("title") -> {
val (key, value) = line.split("=", limit = 2)
val index = key.removePrefix("Title").removePrefix("title").toIntOrNull() ?: continue
titles[index] = value.trim()
}
}
}
return files.keys.sorted().map { index ->
val url = files[index]!!
ParsedStation(
name = titles[index] ?: url.substringAfterLast("/"),
url = url
)
}
}
}
Step 9: Run PLS tests to verify they pass
Run: ./gradlew test --tests "*.PlsParserTest" -v
Expected: PASS
Step 10: Write failing exporter tests and implement PlaylistExporter
PlaylistExporter has two functions: toM3u(stations: List<Station>): String and toPls(stations: List<Station>): String. Writes #EXTIMG lines for stations with defaultArtworkUrl. Test round-trip: export → parse → compare.
Step 11: Run all import/export tests
Run: ./gradlew test --tests "*.import.*" -v
Expected: ALL PASS
Step 12: Commit
git add -A
git commit -m "feat: add M3U/PLS import and export with EXTIMG support"
Task 4: Audio Engine — IcyParser
The IcyParser separates audio bytes from ICY metadata in a Shoutcast/Icecast stream. This is pure byte-level logic with no Android dependencies — fully unit-testable.
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/audio/IcyParser.kt - Test:
app/src/test/java/xyz/cottongin/radio247/audio/IcyParserTest.kt
Step 1: Write failing IcyParser tests
Test cases:
- Correctly separates audio bytes from metadata given a known metaint
- Parses
StreamTitle='Artist - Song';into artist + title - Handles empty metadata blocks (length byte = 0)
- Handles metadata with no
-separator (title only, no artist) - Passthrough mode when metaint is null (no metadata in stream)
- Handles metadata spanning the maximum size (255 × 16 = 4080 bytes)
- Handles multiple consecutive audio+metadata cycles
The IcyParser reads from an InputStream and emits two things: audio bytes (via a callback or output stream) and metadata events. Design it as:
class IcyParser(
private val input: InputStream,
private val metaint: Int?,
private val onAudioData: (ByteArray, Int, Int) -> Unit,
private val onMetadata: (IcyMetadata) -> Unit
)
data class IcyMetadata(
val raw: String,
val title: String?,
val artist: String?
)
Test by constructing a ByteArrayInputStream with hand-crafted ICY stream bytes:
@Test
fun `separates audio and metadata`() {
val metaint = 8
val audioChunk = ByteArray(8) { 0x42 }
val metaString = "StreamTitle='Test Title';"
val metaPadded = padMetadata(metaString) // pad to 16-byte boundary
val metaLengthByte = (metaPadded.size / 16).toByte()
val stream = ByteArrayInputStream(
audioChunk + byteArrayOf(metaLengthByte) + metaPadded + audioChunk + byteArrayOf(0)
)
val audioCollected = ByteArrayOutputStream()
val metadataCollected = mutableListOf<IcyMetadata>()
val parser = IcyParser(stream, metaint,
onAudioData = { buf, off, len -> audioCollected.write(buf, off, len) },
onMetadata = { metadataCollected.add(it) }
)
parser.readAll()
assertEquals(16, audioCollected.size()) // 8 + 8 from two cycles
assertEquals(1, metadataCollected.size)
assertEquals("Test Title", metadataCollected[0].title)
}
Step 2: Run tests to verify they fail
Run: ./gradlew test --tests "*.IcyParserTest" -v
Expected: FAIL
Step 3: Implement IcyParser
Core loop:
fun readAll() {
if (metaint == null) {
// Passthrough — no metadata in this stream
val buf = ByteArray(8192)
while (true) {
val read = input.read(buf)
if (read == -1) break
onAudioData(buf, 0, read)
}
return
}
val audioBuf = ByteArray(metaint)
while (true) {
// Read exactly metaint audio bytes
val audioRead = input.readFully(audioBuf, 0, metaint)
if (audioRead < metaint) break
onAudioData(audioBuf, 0, metaint)
// Read metadata length byte
val lengthByte = input.read()
if (lengthByte == -1) break
val metaLength = lengthByte * 16
if (metaLength == 0) continue
// Read metadata
val metaBuf = ByteArray(metaLength)
val metaRead = input.readFully(metaBuf, 0, metaLength)
if (metaRead < metaLength) break
val metaString = String(metaBuf, Charsets.UTF_8).trimEnd('\u0000')
onMetadata(parseMetaString(metaString))
}
}
parseMetaString extracts StreamTitle value, attempts split on - for artist/title.
Provide a readFully extension on InputStream that loops until requested bytes are read or EOF.
Step 4: Run tests to verify they pass
Run: ./gradlew test --tests "*.IcyParserTest" -v
Expected: PASS
Step 5: Commit
git add -A
git commit -m "feat: add ICY metadata parser with artist/title extraction"
Task 5: Audio Engine — Mp3FrameSync
Finds MP3 frame boundaries in a raw byte stream. Pure logic, no Android dependencies.
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/audio/Mp3FrameSync.kt - Test:
app/src/test/java/xyz/cottongin/radio247/audio/Mp3FrameSyncTest.kt
Step 1: Write failing tests
Test cases:
- Finds valid MP3 frame at start of data
- Finds frame after garbage bytes (re-sync)
- Correctly calculates frame size from header (MPEG1 Layer3 at various bitrates)
- Handles padding bit
- Emits complete frames via callback
- Handles truncated frame at end of data (discards it)
- Validates frame header (rejects invalid bitrate/sample rate combos)
Reference: MP3 frame header is 4 bytes. Byte 0-1 contain sync word (11 bits of 1s). Bytes 1-3 contain MPEG version, layer, bitrate index, sample rate index, padding.
Frame size = (144 * bitrate / sampleRate) + padding
class Mp3FrameSyncTest {
@Test
fun `finds frame at start of data`() {
// MPEG1 Layer3, 128kbps, 44100Hz, no padding = 417 bytes
val header = byteArrayOf(0xFF.toByte(), 0xFB.toByte(), 0x90.toByte(), 0x00)
val frameBody = ByteArray(417 - 4) // fill with zeros
val frame = header + frameBody
val frames = mutableListOf<ByteArray>()
val sync = Mp3FrameSync { frames.add(it) }
sync.feed(frame)
sync.flush()
assertEquals(1, frames.size)
assertEquals(417, frames[0].size)
}
@Test
fun `re-syncs after garbage bytes`() {
val garbage = ByteArray(100) { 0x42 }
val header = byteArrayOf(0xFF.toByte(), 0xFB.toByte(), 0x90.toByte(), 0x00)
val frameBody = ByteArray(413)
val frame = header + frameBody
val frames = mutableListOf<ByteArray>()
val sync = Mp3FrameSync { frames.add(it) }
sync.feed(garbage + frame)
sync.flush()
assertEquals(1, frames.size)
}
}
Step 2: Run tests to verify they fail
Run: ./gradlew test --tests "*.Mp3FrameSyncTest" -v
Expected: FAIL
Step 3: Implement Mp3FrameSync
The sync maintains an internal byte buffer. feed(bytes) appends to the buffer. After each feed, it scans for frame headers, validates them, reads the full frame, and emits via callback.
Key implementation details:
- Bitrate table for MPEG1 Layer 3:
[0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0](index 0 and 15 are invalid) - Sample rate table for MPEG1:
[44100, 48000, 32000, 0] - Frame size formula:
144 * bitrate * 1000 / sampleRate + padding - After finding a valid header and reading the frame, verify the next bytes also start with a valid sync word (two-frame validation) to reduce false sync hits in corrupted data.
Step 4: Run tests to verify they pass
Run: ./gradlew test --tests "*.Mp3FrameSyncTest" -v
Expected: PASS
Step 5: Commit
git add -A
git commit -m "feat: add MP3 frame synchronizer with re-sync and validation"
Task 6: Audio Engine — StreamConnection
HTTP connection to the radio stream using OkHttp. Requests ICY metadata. Tested with MockWebServer.
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/audio/StreamConnection.kt - Test:
app/src/test/java/xyz/cottongin/radio247/audio/StreamConnectionTest.kt
Step 1: Write failing tests
class StreamConnectionTest {
@get:Rule val server = MockWebServer()
@Test
fun `sends icy-metadata header and reads metaint from response`() {
server.enqueue(MockResponse()
.setHeader("icy-metaint", "16000")
.setHeader("Content-Type", "audio/mpeg")
.setBody("fake audio data"))
val conn = StreamConnection(server.url("/stream").toString())
conn.open()
val request = server.takeRequest()
assertEquals("1", request.getHeader("Icy-MetaData"))
assertEquals(16000, conn.metaint)
assertNotNull(conn.inputStream)
conn.close()
}
@Test
fun `metaint is null when server does not provide it`() { ... }
@Test
fun `throws ConnectionFailed on HTTP error`() { ... }
@Test
fun `throws ConnectionFailed on network error`() { ... }
}
Step 2: Run tests to verify they fail
Run: ./gradlew test --tests "*.StreamConnectionTest" -v
Expected: FAIL
Step 3: Implement StreamConnection
class StreamConnection(private val url: String) {
private val client = OkHttpClient.Builder()
.readTimeout(Duration.ofSeconds(30))
.build()
var metaint: Int? = null
private set
var inputStream: InputStream? = null
private set
private var response: Response? = null
fun open() {
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()
throw ConnectionFailed("HTTP ${resp.code}")
}
response = resp
metaint = resp.header("icy-metaint")?.toIntOrNull()
inputStream = resp.body?.byteStream()
?: throw ConnectionFailed("Empty response body")
} catch (e: IOException) {
throw ConnectionFailed("Network error", e)
}
}
fun close() {
response?.close()
response = null
inputStream = null
}
}
class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
Step 4: Run tests to verify they pass
Run: ./gradlew test --tests "*.StreamConnectionTest" -v
Expected: PASS
Step 5: Commit
git add -A
git commit -m "feat: add HTTP stream connection with ICY header support"
Task 7: Audio Engine — Integration (AudioEngine)
Wires all stages together. This class coordinates the full pipeline: StreamConnection → IcyParser → (optional ring buffer) → Mp3FrameSync → MediaCodec → AudioTrack.
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/audio/AudioEngine.kt - Create:
app/src/main/java/xyz/cottongin/radio247/audio/AudioEngineState.kt - Create:
app/src/main/java/xyz/cottongin/radio247/audio/RingBuffer.kt
Step 1: Define AudioEngineState sealed class
sealed interface AudioEngineEvent {
data class MetadataChanged(val metadata: IcyMetadata) : AudioEngineEvent
data class Error(val cause: EngineError) : AudioEngineEvent
data object Started : AudioEngineEvent
data object Stopped : AudioEngineEvent
}
sealed interface EngineError {
data class ConnectionFailed(val cause: Throwable) : EngineError
data object StreamEnded : EngineError
data class DecoderError(val cause: Throwable) : EngineError
data class AudioOutputError(val cause: Throwable) : EngineError
}
Step 2: Implement RingBuffer
A simple fixed-capacity byte-array ring buffer for the configurable audio buffer between frame sync and decoder. When capacity is 0, write() calls read callback immediately (passthrough).
Step 3: Implement AudioEngine
class AudioEngine(
private val url: String,
private val bufferMs: Int = 0
) {
private val _events = MutableSharedFlow<AudioEngineEvent>(extraBufferCapacity = 16)
val events: SharedFlow<AudioEngineEvent> = _events
private var thread: Thread? = null
@Volatile private var running = false
val pendingLatencyMs: Long
get() {
// Estimate from AudioTrack write head vs play head
// + ring buffer contents
// Updated by the engine thread
return _estimatedLatencyMs.get()
}
fun start() {
running = true
thread = Thread({
try {
runPipeline()
} catch (e: Exception) {
if (running) {
_events.tryEmit(AudioEngineEvent.Error(categorizeError(e)))
}
} finally {
_events.tryEmit(AudioEngineEvent.Stopped)
}
}, "AudioEngine").apply { start() }
}
fun stop() {
running = false
thread?.interrupt()
thread = null
}
private fun runPipeline() {
val connection = StreamConnection(url)
connection.open()
val sampleRate = 44100 // Will be refined from first MP3 frame header
val channels = AudioFormat.CHANNEL_OUT_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channels, encoding)
val audioTrack = AudioTrack.Builder()
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build())
.setAudioFormat(AudioFormat.Builder()
.setSampleRate(sampleRate)
.setChannelMask(channels)
.setEncoding(encoding)
.build())
.setBufferSizeInBytes(minBuf)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
audioTrack.play()
// Configure MediaCodec for MP3
val codec = MediaCodec.createDecoderByType("audio/mpeg")
val format = MediaFormat.createAudioFormat("audio/mpeg", sampleRate, 2)
codec.configure(format, null, null, 0)
codec.start()
_events.tryEmit(AudioEngineEvent.Started)
try {
val frameSync = Mp3FrameSync { mp3Frame ->
decodeToPcm(codec, mp3Frame, audioTrack)
}
val icyParser = IcyParser(
input = connection.inputStream!!,
metaint = connection.metaint,
onAudioData = { buf, off, len -> frameSync.feed(buf, off, len) },
onMetadata = { _events.tryEmit(AudioEngineEvent.MetadataChanged(it)) }
)
icyParser.readAll() // Blocks until stream ends or error
} finally {
codec.stop()
codec.release()
audioTrack.stop()
audioTrack.release()
connection.close()
}
}
private fun decodeToPcm(codec: MediaCodec, mp3Frame: ByteArray, audioTrack: AudioTrack) {
// Feed MP3 frame to codec input
val inIdx = codec.dequeueInputBuffer(1000)
if (inIdx >= 0) {
val inBuf = codec.getInputBuffer(inIdx)!!
inBuf.clear()
inBuf.put(mp3Frame)
codec.queueInputBuffer(inIdx, 0, mp3Frame.size, 0, 0)
}
// Pull decoded PCM from codec output
val bufferInfo = MediaCodec.BufferInfo()
var outIdx = codec.dequeueOutputBuffer(bufferInfo, 1000)
while (outIdx >= 0) {
val outBuf = codec.getOutputBuffer(outIdx)!!
val pcmData = ByteArray(bufferInfo.size)
outBuf.get(pcmData)
outBuf.clear()
codec.releaseOutputBuffer(outIdx, false)
audioTrack.write(pcmData, 0, pcmData.size)
outIdx = codec.dequeueOutputBuffer(bufferInfo, 0)
}
}
}
Note: AudioEngine uses Android APIs (MediaCodec, AudioTrack) so it can't be fully unit-tested without an Android device. The individual stages (IcyParser, Mp3FrameSync, StreamConnection) are tested independently. Integration testing of the full pipeline will be done manually on-device with real streams.
Step 4: Verify build
Run: ./gradlew assembleDebug
Expected: BUILD SUCCESSFUL
Step 5: Commit
git add -A
git commit -m "feat: integrate audio engine pipeline with MediaCodec and AudioTrack"
Task 8: Foreground Service & Stay Connected
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt - Create:
app/src/main/java/xyz/cottongin/radio247/service/PlaybackState.kt - Create:
app/src/main/java/xyz/cottongin/radio247/service/NotificationHelper.kt - Modify:
app/src/main/java/xyz/cottongin/radio247/RadioApplication.kt(expose DB + prefs)
Step 1: Define PlaybackState
sealed interface PlaybackState {
data object Idle : PlaybackState
data class Playing(
val station: Station,
val metadata: IcyMetadata? = null,
val sessionStartedAt: Long = System.currentTimeMillis(),
val connectionStartedAt: Long = System.currentTimeMillis()
) : PlaybackState
data class Reconnecting(
val station: Station,
val metadata: IcyMetadata? = null,
val sessionStartedAt: Long,
val attempt: Int = 1
) : PlaybackState
}
Step 2: Implement NotificationHelper
Creates and manages the foreground notification. Uses NotificationCompat with a MEDIA channel. Shows station name, track title, album art bitmap, and a stop action. Updates notification content without recreating it (use same notification ID).
Step 3: Implement RadioPlaybackService
Key responsibilities:
- Start/stop audio engine
- Manage
MediaSession(metadata, transport controls) - Acquire/release
WakeLockandWifiManager.WifiLock - Stay Connected reconnection loop (exponential backoff,
ConnectivityManagercallback) - Persist
ListeningSessionandConnectionSpanrows - Persist
MetadataSnapshoton ICY metadata changes - Expose
PlaybackStateasStateFlowfor the UI to observe
Reconnection logic:
private suspend fun reconnectLoop(station: Station, sessionId: Long) {
var attempt = 0
while (stayConnected && running) {
attempt++
_state.value = PlaybackState.Reconnecting(station, attempt = attempt, ...)
updateNotification("Reconnecting... (attempt $attempt)")
try {
startEngine(station, sessionId)
return // Connected successfully
} catch (e: Exception) {
val delayMs = min(1000L * (1 shl (attempt - 1)), 30_000L)
delay(delayMs)
}
}
}
Also register a ConnectivityManager.NetworkCallback — when network becomes available, cancel the current backoff delay and retry immediately.
Step 4: Wire up Application class
RadioApplication provides:
RadioDatabasesingleton viaRoom.databaseBuilderRadioPreferencesinstance- A way for the service and UI to share state (a
RadioControllersingleton or direct service binding)
Step 5: Verify build
Run: ./gradlew assembleDebug
Expected: BUILD SUCCESSFUL
Step 6: Commit
git add -A
git commit -m "feat: add foreground playback service with Stay Connected reconnection"
Task 9: UI — Theme & Navigation
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/ui/theme/Theme.kt - Create:
app/src/main/java/xyz/cottongin/radio247/ui/theme/Color.kt - Create:
app/src/main/java/xyz/cottongin/radio247/ui/theme/Type.kt - Create:
app/src/main/java/xyz/cottongin/radio247/ui/navigation/Screen.kt - Modify:
app/src/main/java/xyz/cottongin/radio247/MainActivity.kt
Step 1: Define theme
Use Material 3 dynamic color where available (Android 12+), fall back to a custom dark/light scheme for API 28+. Define a Radio247Theme composable.
Step 2: Define navigation
sealed class Screen {
data object StationList : Screen()
data object NowPlaying : Screen()
data object Settings : Screen()
}
Simple state-driven navigation in MainActivity:
var currentScreen by remember { mutableStateOf<Screen>(Screen.StationList) }
Radio247Theme {
when (currentScreen) {
Screen.StationList -> StationListScreen(
onNavigateToNowPlaying = { currentScreen = Screen.NowPlaying },
onNavigateToSettings = { currentScreen = Screen.Settings }
)
Screen.NowPlaying -> NowPlayingScreen(
onBack = { currentScreen = Screen.StationList }
)
Screen.Settings -> SettingsScreen(
onBack = { currentScreen = Screen.StationList }
)
}
}
Handle system back button via BackHandler.
Step 3: Verify build
Run: ./gradlew assembleDebug
Expected: BUILD SUCCESSFUL
Step 4: Commit
git add -A
git commit -m "feat: add Material 3 theme and screen navigation"
Task 10: UI — Station List Screen
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListScreen.kt - Create:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/StationListViewModel.kt - Create:
app/src/main/java/xyz/cottongin/radio247/ui/components/MiniPlayer.kt - Create:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddStationDialog.kt - Create:
app/src/main/java/xyz/cottongin/radio247/ui/screens/stationlist/AddPlaylistDialog.kt
Step 1: Implement StationListViewModel
Exposes:
playlists: StateFlow<List<PlaylistWithStations>>(playlist + its stations, ordered by starred desc then sortOrder)unsortedStations: StateFlow<List<Station>>(stations with null playlistId)playbackState: StateFlow<PlaybackState>(from service)- Functions:
playStation(station),toggleStar(station),deleteStation(station),addStation(name, url, playlistId),addPlaylist(name),reorderStation(from, to),reorderPlaylist(from, to),importFile(uri)(detects format, parses, inserts)
Step 2: Implement StationListScreen
TopAppBarwith title "24/7 Radio" and action icons (Import, Add Station, Add Playlist, Settings)LazyColumnbody:- "Unsorted" section (if any ungrouped stations exist)
- Each playlist as an expandable section header (tap to expand/collapse)
- Station rows within each section
- Each station row: drag handle, star icon, station name, "now playing" indicator
- Drag-to-reorder via
rememberReorderableLazyListState(useorg.burnoutcrew.reorderablelibrary or implement manually withdetectDragGestures) ScaffoldwithbottomBar=MiniPlayer(visible when playback active)- Long-press station → context menu (Edit, Delete)
- Add Station dialog: name + URL text fields, playlist dropdown
- Add Playlist dialog: name text field
- Import: launch
ACTION_OPEN_DOCUMENTintent for.m3u/.pls, parse result, show confirmation
Step 3: Implement MiniPlayer
@Composable
fun MiniPlayer(
playbackState: PlaybackState.Playing,
onTap: () -> Unit,
onStop: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth().clickable(onClick = onTap),
tonalElevation = 4.dp
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(playbackState.station.name, style = MaterialTheme.typography.bodyMedium)
playbackState.metadata?.let {
Text(it.raw, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
IconButton(onClick = onStop) {
Icon(Icons.Default.Stop, contentDescription = "Stop")
}
}
}
}
Step 4: Verify build and test on device
Run: ./gradlew assembleDebug
Install on device, verify station list displays, add/delete/star/reorder works, import PLS/M3U works.
Step 5: Commit
git add -A
git commit -m "feat: add Station List screen with playlists, drag-reorder, and import"
Task 11: UI — Now Playing Screen
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt - Create:
app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingViewModel.kt
Step 1: Implement NowPlayingViewModel
Exposes:
playbackState: StateFlow<PlaybackState>sessionElapsed: StateFlow<Duration>(ticks every second, calculates fromsessionStartedAt)connectionElapsed: StateFlow<Duration>(ticks every second, calculates fromconnectionStartedAt)estimatedLatencyMs: StateFlow<Long>(reads from AudioEngine)stayConnected: StateFlow<Boolean>(from prefs)bufferMs: StateFlow<Int>(from prefs)- Functions:
stop(),toggleStayConnected(),setBufferMs(ms)
Timer implementation: a coroutine that emits every 1 second using delay(1000). Both timers tick from the same coroutine. Session timer reads sessionStartedAt from PlaybackState. Connection timer reads connectionStartedAt.
Step 2: Implement NowPlayingScreen
Layout (top to bottom):
- Back arrow in top bar
- Album art (large, centered, 200dp+ square)
- Station name
- Track title + artist (or "No track info")
- Divider
- Session timer: "Session: 1h 23m 45s"
- Connection timer: "Connected: 14m 22s"
- Latency indicator: "Latency: ~52ms"
- Divider
- Stay Connected toggle row
- Buffer slider row (label shows current ms value)
- Stop button (prominent, centered)
Connection status overlay: when PlaybackState.Reconnecting, show a semi-transparent overlay with "Reconnecting... (attempt N)" and a progress indicator.
Step 3: Verify on device
Run on device, play a station, verify:
- Metadata updates
- Both timers tick
- Latency shows a reasonable value
- Stay Connected toggle works
- Stop button works
- Reconnecting state displays correctly (test by toggling airplane mode)
Step 4: Commit
git add -A
git commit -m "feat: add Now Playing screen with dual timers and latency indicator"
Task 12: UI — Settings Screen
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsScreen.kt - Create:
app/src/main/java/xyz/cottongin/radio247/ui/screens/settings/SettingsViewModel.kt
Step 1: Implement SettingsViewModel
Exposes preferences as state, plus:
recentStations: StateFlow<List<ListeningSession>>(recent sessions with station info)trackHistory: StateFlow<List<MetadataSnapshot>>(recent metadata, paginated)trackSearchQuery: StateFlow<String>- Functions:
exportPlaylist(playlistId, format, uri),setTrackSearchQuery(query)
Step 2: Implement SettingsScreen
Sections:
- Playback — Stay Connected toggle, Buffer slider
- Export — Button to pick playlist and format, launches
ACTION_CREATE_DOCUMENT - Recently Played — List of station names with "last listened" timestamps
- Track History — Search bar + scrollable list of "Artist - Title" with station name and timestamp
Step 3: Verify on device
Test export (open exported file in another app to verify format). Check history populates after listening.
Step 4: Commit
git add -A
git commit -m "feat: add Settings screen with export, history, and track search"
Task 13: Metadata — Album Art Resolution
Files:
- Create:
app/src/main/java/xyz/cottongin/radio247/metadata/AlbumArtResolver.kt - Create:
app/src/main/java/xyz/cottongin/radio247/metadata/ArtCache.kt - Test:
app/src/test/java/xyz/cottongin/radio247/metadata/AlbumArtResolverTest.kt
Step 1: Write failing tests for AlbumArtResolver
Test cases:
- Returns cached URL if available (cache hit)
- Queries MusicBrainz when artist and title present
- Skips MusicBrainz when no
-separator in metadata (spoken word) - Falls through to ICY StreamUrl when MusicBrainz returns nothing
- Falls through to station defaultArtworkUrl when ICY StreamUrl is null
- Returns null (placeholder) when all lookups fail
- Respects MusicBrainz rate limit (1 req/sec)
Step 2: Run tests to verify they fail
Run: ./gradlew test --tests "*.AlbumArtResolverTest" -v
Expected: FAIL
Step 3: Implement AlbumArtResolver
class AlbumArtResolver(
private val client: OkHttpClient,
private val artCache: ArtCache
) {
suspend fun resolve(
artist: String?,
title: String?,
icyStreamUrl: String?,
stationArtworkUrl: String?
): String? {
val cacheKey = "${artist.orEmpty()}-${title.orEmpty()}"
artCache.get(cacheKey)?.let { return it }
// 1. MusicBrainz — only if we have artist AND title
if (artist != null && title != null) {
val artUrl = queryMusicBrainz(artist, title)
if (artUrl != null) {
artCache.put(cacheKey, artUrl)
return artUrl
}
}
// 2. ICY StreamUrl
if (icyStreamUrl != null && isImageUrl(icyStreamUrl)) {
artCache.put(cacheKey, icyStreamUrl)
return icyStreamUrl
}
// 3. Station default artwork
if (stationArtworkUrl != null) {
return stationArtworkUrl
}
// 4. Station favicon — skip for V1, would require HTML parsing
// 5. Null → caller shows placeholder
return null
}
private suspend fun queryMusicBrainz(artist: String, title: String): String? {
// Query: https://musicbrainz.org/ws/2/recording?query=artist:"$artist" AND recording:"$title"&fmt=json&limit=1
// Extract release ID from first result
// Then: https://coverartarchive.org/release/$releaseId/front-250
// If 200, return that URL. If 404, return null.
// Rate limit: delay(1000) between requests
}
}
Step 4: Implement ArtCache
Simple LRU disk cache. Key = "artist-title" SHA-256 hash. Value = URL string stored in a small SQLite table or flat file. Bounded by entry count (e.g., 5000 entries). For the actual image bitmap caching, rely on OkHttp's cache or Coil (image loading library — add as dependency).
Consider adding Coil to dependencies for image loading in Compose:
# In libs.versions.toml
coil = "3.1.0"
[libraries]
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
Use AsyncImage from Coil in Compose to load album art URLs with automatic caching.
Step 5: Run tests to verify they pass
Run: ./gradlew test --tests "*.AlbumArtResolverTest" -v
Expected: PASS
Step 6: Wire album art into Now Playing and notification
NowPlayingScreen: UseAsyncImage(model = artUrl, ...)with placeholder drawableNotificationHelper: Load bitmap via Coil'sImageLoader.execute(), set on notification viasetLargeIcon()MediaSession: Set artwork bitmap viaMediaMetadataCompat.Builder().putBitmap(METADATA_KEY_ART, bitmap)
Step 7: Commit
git add -A
git commit -m "feat: add album art resolution with MusicBrainz and fallback chain"
Task 14: Final Integration & Polish
Files:
- Modify: various files for wiring everything together
- Create:
app/src/main/res/drawable/ic_launcher_foreground.xml(app icon)
Step 1: Wire service binding to UI
Ensure MainActivity binds to RadioPlaybackService and ViewModels can observe PlaybackState. Options:
- Use a
RadioControllersingleton inRadioApplicationthat both the service and UI access - Or use
LocalBroadcastManager/SharedFlowexposed via Application class
Recommended: A RadioController object that holds the shared MutableStateFlow<PlaybackState> and provides play(station), stop() functions that start/communicate with the service.
Step 2: Handle Android lifecycle edge cases
- Notification permission request on Android 13+ (POST_NOTIFICATIONS)
- Audio focus: request
AUDIOFOCUS_GAINwhen playing, release on stop. Don't duck or pause for transient focus loss (this is a 24/7 radio — user expects it to keep playing). - Handle
ACTION_AUDIO_BECOMING_NOISY(headphones unplugged) — pause playback.
Step 3: Test end-to-end on device
Manual test checklist:
- Add station manually, verify it appears in list
- Import M3U file, verify stations appear with artwork URLs
- Play a station, verify audio comes out
- Verify metadata appears on Now Playing and notification
- Verify album art loads (or placeholder shows)
- Verify session timer and connection timer tick
- Enable Stay Connected, toggle airplane mode, verify reconnection
- Verify dual timers: session keeps counting, connection resets on reconnect
- Star a station, verify it moves to top
- Reorder stations via drag
- Export playlist as M3U, verify file content
- Check Track History in Settings after listening
- Kill app from recents, verify foreground service keeps playing
- Verify lockscreen controls work
- Verify Bluetooth headset button works
Step 4: Commit
git add -A
git commit -m "feat: wire service to UI, handle lifecycle, final integration"
Task 15: App Icon & README
Files:
- Create:
app/src/main/res/mipmap-*/ic_launcher.webp(or adaptive icon XMLs) - Create:
README.md
Step 1: Create a simple adaptive icon
Vector drawable foreground with a radio/antenna icon. Use Material Icons or a simple custom SVG.
Step 2: Write README
Cover: what the app does, how to build (./gradlew assembleDebug), how to import stations (M3U/PLS), key features, architecture overview (link to design doc).
Step 3: Commit
git add -A
git commit -m "docs: add app icon and README"
Dependency Summary
| Library | Purpose |
|---|---|
| Jetpack Compose + Material 3 | UI |
| Room | Database (stations, playlists, metadata, sessions) |
| DataStore | Preferences |
| OkHttp | HTTP stream connections |
| Coil 3 | Image loading and caching for album art |
| MediaCodec (Android SDK) | MP3 decoding |
| AudioTrack (Android SDK) | PCM audio output |
| MediaSession (AndroidX) | Lockscreen/notification/Bluetooth controls |
| MockWebServer (test) | HTTP testing for StreamConnection |
| MockK (test) | Mocking |
| Turbine (test) | Flow testing |
| JUnit 4 (test) | Test framework |