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