diff --git a/docs/plans/2026-03-09-android-247-radio-implementation.md b/docs/plans/2026-03-09-android-247-radio-implementation.md new file mode 100644 index 0000000..631a1cd --- /dev/null +++ b/docs/plans/2026-03-09-android-247-radio-implementation.md @@ -0,0 +1,1649 @@ +# 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 |