# 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
```
**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>` 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 = dataStore.data.map { it[STAY_CONNECTED] ?: false }
val bufferMs: Flow = dataStore.data.map { it[BUFFER_MS] ?: 0 }
val lastStationId: Flow = 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 {
val stations = mutableListOf()
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 {
val files = mutableMapOf()
val titles = mutableMapOf()
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): String` and `toPls(stations: List): 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()
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()
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()
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(extraBufferCapacity = 16)
val events: SharedFlow = _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.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>` (playlist + its stations, ordered by starred desc then sortOrder)
- `unsortedStations: StateFlow>` (stations with null playlistId)
- `playbackState: StateFlow` (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`
- `sessionElapsed: StateFlow` (ticks every second, calculates from `sessionStartedAt`)
- `connectionElapsed: StateFlow` (ticks every second, calculates from `connectionStartedAt`)
- `estimatedLatencyMs: StateFlow` (reads from AudioEngine)
- `stayConnected: StateFlow` (from prefs)
- `bufferMs: StateFlow` (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>` (recent sessions with station info)
- `trackHistory: StateFlow>` (recent metadata, paginated)
- `trackSearchQuery: StateFlow`
- 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` 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 |