# 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`:

```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`:

```kotlin
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`:

```kotlin
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`:

```kotlin
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
<?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**

```bash
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`:
```kotlin
@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`:
```kotlin
@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`:
```kotlin
@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`:
```kotlin
@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`:
```kotlin
@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**

```kotlin
@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**

```kotlin
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**

```bash
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**

```kotlin
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`)

```kotlin
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**

```kotlin
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

```kotlin
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**

```kotlin
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**

```bash
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:

```kotlin
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:

```kotlin
@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:
```kotlin
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**

```bash
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`

```kotlin
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**

```bash
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**

```kotlin
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**

```kotlin
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**

```bash
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**

```kotlin
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**

```kotlin
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**

```bash
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**

```kotlin
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:
```kotlin
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**

```bash
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**

```kotlin
sealed class Screen {
    data object StationList : Screen()
    data object NowPlaying : Screen()
    data object Settings : Screen()
}
```

Simple state-driven navigation in `MainActivity`:

```kotlin
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**

```bash
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**

```kotlin
@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**

```bash
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**

```bash
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**

```bash
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**

```kotlin
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:

```toml
# 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**

```bash
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**

```bash
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**

```bash
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 |
