Files
Android-247-Radio/docs/plans/2026-03-09-android-247-radio-implementation.md
cottongin 806ce23254 docs: add implementation plan (15 tasks, TDD, bite-sized steps)
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
2026-03-10 00:23:39 -04:00

54 KiB
Raw Blame History

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 suspend functions 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 #EXTINF and URLs
  • Parse M3U with #EXTIMG artwork 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 WakeLock and WifiManager.WifiLock
  • Stay Connected reconnection loop (exponential backoff, ConnectivityManager callback)
  • Persist ListeningSession and ConnectionSpan rows
  • Persist MetadataSnapshot on ICY metadata changes
  • Expose PlaybackState as StateFlow for 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:

  • RadioDatabase singleton via Room.databaseBuilder
  • RadioPreferences instance
  • A way for the service and UI to share state (a RadioController singleton 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

  • TopAppBar with title "24/7 Radio" and action icons (Import, Add Station, Add Playlist, Settings)
  • LazyColumn body:
    • "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 (use org.burnoutcrew.reorderable library or implement manually with detectDragGestures)
  • Scaffold with bottomBar = 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_DOCUMENT intent 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 from sessionStartedAt)
  • connectionElapsed: StateFlow<Duration> (ticks every second, calculates from connectionStartedAt)
  • 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: Use AsyncImage(model = artUrl, ...) with placeholder drawable
  • NotificationHelper: Load bitmap via Coil's ImageLoader.execute(), set on notification via setLargeIcon()
  • MediaSession: Set artwork bitmap via MediaMetadataCompat.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 RadioController singleton in RadioApplication that both the service and UI access
  • Or use LocalBroadcastManager / SharedFlow exposed 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_GAIN when 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