Compare commits
10 Commits
d451d005c0
...
a09c50c302
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a09c50c302
|
||
|
|
639cb99d1f
|
||
|
|
e03e32183b
|
||
|
|
7cd9d249d8
|
||
|
|
a5057be3c7
|
||
|
|
bb35ec8a8b
|
||
|
|
14aeeecd9c
|
||
|
|
2e615850bc
|
||
|
|
ada81dddd0
|
||
|
|
5105794120
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -41,3 +41,6 @@ local.properties
|
||||
# Build script output
|
||||
dist/
|
||||
keystore/
|
||||
|
||||
# APKMirror bundles
|
||||
*.apkm
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 cottongin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
143
README.md
143
README.md
@@ -1,36 +1,149 @@
|
||||
# 24/7 Radio
|
||||
|
||||
Personal-use Android app for 24/7 internet radio streaming.
|
||||
Android app for 24/7 internet radio streaming with minimum latency, aggressive reconnection, and full Icecast/Shoutcast metadata support.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project was built with the assistance of [Cursor](https://cursor.com) (Claude Opus/Sonnet 4.6).
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Station List | Now Playing | Mini-Player | Station Art Fallback |
|
||||
|:---:|:---:|:---:|:---:|
|
||||
|  |  |  |  |
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Custom Raw Audio Pipeline** — OkHttp → IcyParser → Mp3FrameSync → MediaCodec → AudioTrack for absolute minimum latency (~26ms per MP3 frame)
|
||||
- **Stay Connected Mode** — Aggressive reconnection with exponential backoff, never gives up
|
||||
- **Dual Timers** — Session elapsed time and connection elapsed time
|
||||
- **Latency Indicator** — Estimated stream-to-speaker latency
|
||||
- **Icecast/Shoutcast Metadata** — Track title, artist extraction from ICY protocol
|
||||
- **Album Art** — MusicBrainz/Cover Art Archive lookup with fallback chain
|
||||
- **Playlist Management** — PLS/M3U import/export with #EXTIMG support
|
||||
- **Station Organization** — Playlists, starring/favoriting, manual reorder
|
||||
### Playback
|
||||
- **Custom raw audio pipeline** — OkHttp → IcyParser → Mp3FrameSync → MediaCodec → AudioTrack for minimum latency (~26ms per MP3 frame)
|
||||
- **Stay Connected mode** — aggressive reconnection with exponential backoff; never gives up until you say stop
|
||||
- **Configurable buffer** — 0–500ms slider; 0ms keeps you as live as possible, higher values smooth out spotty connections
|
||||
- **Icecast/Shoutcast metadata** — ICY protocol track title and artist extraction
|
||||
- **Album art** — MusicBrainz/Cover Art Archive lookup with a fallback chain (ICY stream URL → station default art → station logo → placeholder)
|
||||
|
||||
### Station Management
|
||||
- **SomaFM built-in** — full SomaFM catalog pre-loaded, sorted by listener count
|
||||
- **Per-station stream quality** — choose 128/256 kbps and SSL/non-SSL per station, or set a global preference order
|
||||
- **PLS/M3U import/export** — with `#EXTIMG` support for station artwork URLs
|
||||
- **Tabs (playlists)** — pin/unpin, rename, and drag-to-reorder tabs and stations within them
|
||||
- **Starring/favoriting** — starred stations sort to the top of their tab
|
||||
|
||||
### Now Playing Screen
|
||||
- **Blurred album art background** — art fills the screen behind the player controls
|
||||
- **Session timer** — total elapsed time since you hit play, not reset on reconnect
|
||||
- **Connection timer** — elapsed time for the current TCP connection, resets on each reconnect
|
||||
- **Latency indicator** — estimated stream-to-speaker latency in ms
|
||||
- **Track history** — every track change is logged to the database with station and timestamp
|
||||
- **Now Playing file logging** — optionally write track history to CSV, JSON Lines, or plain text
|
||||
|
||||
### System Integration
|
||||
- **Media3 notification** — system media notification with album art, transport controls (play/stop + seek-to-live), lockscreen and Bluetooth headset support
|
||||
- **Android Auto** — app registers as a media source; browse your playlists and stations from the car display
|
||||
|
||||
### Build Tooling
|
||||
- **`build.sh`** — interactive menu: release APK (signed), debug APK, keystore management, and clean
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android 9.0+ (API 28)
|
||||
- Internet connection
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
The recommended path is the interactive build script:
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
./build.sh
|
||||
```
|
||||
|
||||
## Import Stations
|
||||
Menu options:
|
||||
1. **Build Release APK** — signed, ready to sideload
|
||||
2. **Build Debug APK** — quick, for testing
|
||||
3. **Manage Keystore** — create or inspect the signing key
|
||||
4. **Clean** — remove build artifacts
|
||||
|
||||
On first run, choose option 3 to create a keystore before building a release APK. Output lands in `dist/`.
|
||||
|
||||
Alternatively, build directly with Gradle:
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug # debug
|
||||
./gradlew assembleRelease # release (requires keystore configured in build.gradle.kts)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Importing Stations
|
||||
|
||||
1. Open the app
|
||||
2. Tap the import icon in the top bar
|
||||
3. Select a .m3u or .pls file
|
||||
4. Stations are added to your list
|
||||
2. Tap the settings gear → Import
|
||||
3. Select a `.m3u` or `.pls` file
|
||||
4. Stations are added to your library
|
||||
|
||||
Exported files include `#EXTIMG` tags for stations that have a default artwork URL set.
|
||||
|
||||
---
|
||||
|
||||
## SomaFM
|
||||
|
||||
The SomaFM catalog is pre-loaded on first launch — no import needed. Stations are sorted by current listener count (refreshable with the sync button). You can set a global stream quality preference (256 kbps SSL recommended) or override it per station via long-press → Quality.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
See [Design Document](docs/plans/2026-03-09-android-247-radio-design.md) and [Implementation Plan](docs/plans/2026-03-09-android-247-radio-implementation.md).
|
||||
Four layers:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ UI Layer │
|
||||
│ Compose screens, ViewModel, StateFlow │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Service Layer │
|
||||
│ RadioPlaybackService (MediaLibrary │
|
||||
│ Service), RadioPlayerAdapter (Media3 │
|
||||
│ Player facade), wake + wifi locks │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Audio Engine │
|
||||
│ StreamConnection → IcyParser → │
|
||||
│ Mp3FrameSync → MediaCodec → AudioTrack │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Data Layer │
|
||||
│ Room DB, PLS/M3U import/export, │
|
||||
│ DataStore preferences │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The audio engine is a standalone component with no Android framework dependencies. The service owns reconnection policy, Android Auto browse tree, and the Media3 session. The UI observes state via `StateFlow` and never touches the engine directly.
|
||||
|
||||
For full detail see the [design document](docs/plans/2026-03-09-android-247-radio-design.md) and [Media3/Android Auto design](docs/plans/2026-03-18-media3-android-auto-design.md).
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Library |
|
||||
|---|---|
|
||||
| Language | Kotlin |
|
||||
| UI | Jetpack Compose, Material Design 3 |
|
||||
| Media session / Auto | AndroidX Media3 (media3-session, media3-common) |
|
||||
| HTTP streaming | OkHttp |
|
||||
| Audio decoding | MediaCodec (hardware-accelerated) |
|
||||
| Audio output | AudioTrack |
|
||||
| Database | Room |
|
||||
| Preferences | DataStore |
|
||||
| Image loading | Coil |
|
||||
| Album art lookup | MusicBrainz / Cover Art Archive (no API key required) |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -24,7 +24,11 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
@@ -69,7 +73,6 @@ dependencies {
|
||||
implementation(libs.material)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network)
|
||||
implementation(libs.cloudy)
|
||||
implementation(libs.palette)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
|
||||
38
app/proguard-rules.pro
vendored
Normal file
38
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Room
|
||||
# Room generates implementation classes via KSP; the generated impls are accessed
|
||||
# reflectively at runtime, so the abstract database and annotated types must be kept.
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-keepclassmembers class * extends androidx.room.RoomDatabase {
|
||||
abstract *;
|
||||
}
|
||||
-keep @androidx.room.Entity class * { *; }
|
||||
-keep @androidx.room.Dao interface * { *; }
|
||||
|
||||
# Media3
|
||||
# RadioPlaybackService is declared in AndroidManifest.xml and instantiated by the system.
|
||||
-keep class xyz.cottongin.radio247.service.RadioPlaybackService { *; }
|
||||
# RadioPlayerAdapter extends SimpleBasePlayer; keep it so the player state machine
|
||||
# callbacks are not stripped.
|
||||
-keep class xyz.cottongin.radio247.service.RadioPlayerAdapter { *; }
|
||||
-keep class * extends androidx.media3.session.MediaLibraryService { *; }
|
||||
-keep class * extends androidx.media3.session.MediaSession$Callback { *; }
|
||||
-keep class * extends androidx.media3.session.MediaLibrarySession$Callback { *; }
|
||||
|
||||
# Coil 3 - custom Transformation subclass
|
||||
-keep class xyz.cottongin.radio247.ui.util.BlurTransformation { *; }
|
||||
|
||||
# Kotlin coroutines
|
||||
# MainDispatcherFactory and CoroutineExceptionHandler are loaded via ServiceLoader /
|
||||
# reflection; keepnames prevents renaming while still allowing shrinking.
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembernames class kotlinx.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
|
||||
# Suppress warnings for optional/transitive dependencies that are not present at runtime
|
||||
-dontwarn org.slf4j.**
|
||||
-dontwarn javax.annotation.**
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn org.bouncycastle.**
|
||||
-dontwarn org.openjsse.**
|
||||
@@ -106,14 +106,14 @@ import androidx.palette.graphics.Palette
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.rememberAsyncImagePainter
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
import coil3.request.transformations
|
||||
import xyz.cottongin.radio247.R
|
||||
import xyz.cottongin.radio247.RadioApplication
|
||||
import xyz.cottongin.radio247.ui.util.BlurTransformation
|
||||
import xyz.cottongin.radio247.audio.IcyMetadata
|
||||
import xyz.cottongin.radio247.audio.MetadataFormatter
|
||||
import xyz.cottongin.radio247.audio.StreamInfo
|
||||
import xyz.cottongin.radio247.service.PlaybackState
|
||||
import com.skydoves.cloudy.cloudy
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
@@ -368,13 +368,11 @@ private fun BlurredBackground(
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(artworkUrl)
|
||||
.size(Size(10, 10))
|
||||
.transformations(BlurTransformation(radius = 25, scale = 0.1f))
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.cloudy(radius = 25)
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package xyz.cottongin.radio247.ui.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import coil3.size.Size
|
||||
import coil3.transform.Transformation
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class BlurTransformation(
|
||||
private val radius: Int = 25,
|
||||
private val scale: Float = 0.1f
|
||||
) : Transformation() {
|
||||
|
||||
override val cacheKey: String = "${javaClass.name}-$radius-$scale"
|
||||
|
||||
override suspend fun transform(input: Bitmap, size: Size): Bitmap =
|
||||
input.stackBlur(scale, radius) ?: input
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
return other is BlurTransformation && radius == other.radius && scale == other.scale
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = 31 * radius.hashCode() + scale.hashCode()
|
||||
}
|
||||
|
||||
private suspend fun Bitmap.stackBlur(scale: Float, radius: Int): Bitmap? =
|
||||
withContext(Dispatchers.IO) {
|
||||
var sentBitmap = this@stackBlur
|
||||
val width = (sentBitmap.width * scale).roundToInt().coerceAtLeast(1)
|
||||
val height = (sentBitmap.height * scale).roundToInt().coerceAtLeast(1)
|
||||
sentBitmap = Bitmap.createScaledBitmap(sentBitmap, width, height, false)
|
||||
val bitmap = sentBitmap.copy(sentBitmap.config ?: Bitmap.Config.ARGB_8888, true)
|
||||
if (radius < 1) {
|
||||
sentBitmap.recycle()
|
||||
bitmap.recycle()
|
||||
return@withContext null
|
||||
}
|
||||
val w = bitmap.width
|
||||
val h = bitmap.height
|
||||
val pix = IntArray(w * h)
|
||||
bitmap.getPixels(pix, 0, w, 0, 0, w, h)
|
||||
val wm = w - 1
|
||||
val hm = h - 1
|
||||
val wh = w * h
|
||||
val div = radius + radius + 1
|
||||
val r = IntArray(wh)
|
||||
val g = IntArray(wh)
|
||||
val b = IntArray(wh)
|
||||
var rsum: Int; var gsum: Int; var bsum: Int
|
||||
var x: Int; var y: Int; var i: Int; var p: Int; var yp: Int; var yi: Int
|
||||
val vmin = IntArray(w.coerceAtLeast(h))
|
||||
var divsum = div + 1 shr 1
|
||||
divsum *= divsum
|
||||
val dv = IntArray(256 * divsum) { it / divsum }
|
||||
yi = 0; var yw = 0
|
||||
val stack = Array(div) { IntArray(3) }
|
||||
var stackpointer: Int; var stackstart: Int; var sir: IntArray; var rbs: Int
|
||||
val r1 = radius + 1
|
||||
var routsum: Int; var goutsum: Int; var boutsum: Int
|
||||
var rinsum: Int; var ginsum: Int; var binsum: Int
|
||||
y = 0
|
||||
while (y < h) {
|
||||
bsum = 0; gsum = 0; rsum = 0; boutsum = 0; goutsum = 0; routsum = 0
|
||||
binsum = 0; ginsum = 0; rinsum = 0
|
||||
i = -radius
|
||||
while (i <= radius) {
|
||||
p = pix[yi + wm.coerceAtMost(i.coerceAtLeast(0))]
|
||||
sir = stack[i + radius]
|
||||
sir[0] = p and 0xff0000 shr 16; sir[1] = p and 0x00ff00 shr 8; sir[2] = p and 0x0000ff
|
||||
rbs = r1 - abs(i)
|
||||
rsum += sir[0] * rbs; gsum += sir[1] * rbs; bsum += sir[2] * rbs
|
||||
if (i > 0) { rinsum += sir[0]; ginsum += sir[1]; binsum += sir[2] }
|
||||
else { routsum += sir[0]; goutsum += sir[1]; boutsum += sir[2] }
|
||||
i++
|
||||
}
|
||||
stackpointer = radius; x = 0
|
||||
while (x < w) {
|
||||
r[yi] = dv[rsum]; g[yi] = dv[gsum]; b[yi] = dv[bsum]
|
||||
rsum -= routsum; gsum -= goutsum; bsum -= boutsum
|
||||
stackstart = stackpointer - radius + div
|
||||
sir = stack[stackstart % div]
|
||||
routsum -= sir[0]; goutsum -= sir[1]; boutsum -= sir[2]
|
||||
if (y == 0) vmin[x] = (x + radius + 1).coerceAtMost(wm)
|
||||
p = pix[yw + vmin[x]]
|
||||
sir[0] = p and 0xff0000 shr 16; sir[1] = p and 0x00ff00 shr 8; sir[2] = p and 0x0000ff
|
||||
rinsum += sir[0]; ginsum += sir[1]; binsum += sir[2]
|
||||
rsum += rinsum; gsum += ginsum; bsum += binsum
|
||||
stackpointer = (stackpointer + 1) % div
|
||||
sir = stack[stackpointer % div]
|
||||
routsum += sir[0]; goutsum += sir[1]; boutsum += sir[2]
|
||||
rinsum -= sir[0]; ginsum -= sir[1]; binsum -= sir[2]
|
||||
yi++; x++
|
||||
}
|
||||
yw += w; y++
|
||||
}
|
||||
x = 0
|
||||
while (x < w) {
|
||||
bsum = 0; gsum = 0; rsum = 0; boutsum = 0; goutsum = 0; routsum = 0
|
||||
binsum = 0; ginsum = 0; rinsum = 0
|
||||
yp = -radius * w; i = -radius
|
||||
while (i <= radius) {
|
||||
yi = 0.coerceAtLeast(yp) + x
|
||||
sir = stack[i + radius]
|
||||
sir[0] = r[yi]; sir[1] = g[yi]; sir[2] = b[yi]
|
||||
rbs = r1 - abs(i)
|
||||
rsum += r[yi] * rbs; gsum += g[yi] * rbs; bsum += b[yi] * rbs
|
||||
if (i > 0) { rinsum += sir[0]; ginsum += sir[1]; binsum += sir[2] }
|
||||
else { routsum += sir[0]; goutsum += sir[1]; boutsum += sir[2] }
|
||||
if (i < hm) yp += w
|
||||
i++
|
||||
}
|
||||
yi = x; stackpointer = radius; y = 0
|
||||
while (y < h) {
|
||||
pix[yi] = -0x1000000 and pix[yi] or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum]
|
||||
rsum -= routsum; gsum -= goutsum; bsum -= boutsum
|
||||
stackstart = stackpointer - radius + div
|
||||
sir = stack[stackstart % div]
|
||||
routsum -= sir[0]; goutsum -= sir[1]; boutsum -= sir[2]
|
||||
if (x == 0) vmin[y] = (y + r1).coerceAtMost(hm) * w
|
||||
p = x + vmin[y]
|
||||
sir[0] = r[p]; sir[1] = g[p]; sir[2] = b[p]
|
||||
rinsum += sir[0]; ginsum += sir[1]; binsum += sir[2]
|
||||
rsum += rinsum; gsum += ginsum; bsum += binsum
|
||||
stackpointer = (stackpointer + 1) % div
|
||||
sir = stack[stackpointer]
|
||||
routsum += sir[0]; goutsum += sir[1]; boutsum += sir[2]
|
||||
rinsum -= sir[0]; ginsum -= sir[1]; binsum -= sir[2]
|
||||
yi += w; y++
|
||||
}
|
||||
x++
|
||||
}
|
||||
bitmap.setPixels(pix, 0, w, 0, 0, w, h)
|
||||
sentBitmap.recycle()
|
||||
bitmap
|
||||
}
|
||||
75
chat-summaries/2026-03-18_10-00-android-auto-testing.md
Normal file
75
chat-summaries/2026-03-18_10-00-android-auto-testing.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Android Auto Testing Attempts & Lessons Learned
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Context:** Follow-up to the Media3 / Android Auto migration session. All implementation was already complete and verified via `dumpsys media_session`. This session focused entirely on visually verifying the Android Auto browse tree using the Desktop Head Unit (DHU).
|
||||
|
||||
---
|
||||
|
||||
## What Was Already Confirmed Working
|
||||
|
||||
Before any testing attempts, `adb shell dumpsys media_session` confirmed:
|
||||
|
||||
- `RadioPlaybackService` registered under both `androidx.media3.session.MediaLibraryService` and `android.media.browse.MediaBrowserService` intent filters
|
||||
- Media session active (`state=3` / playing)
|
||||
- ICY metadata visible to the OS (e.g. "Deep Shit Pt.3, Black Soyls")
|
||||
- Custom "Live" action (Seek to Live) present in the session
|
||||
- Media button receiver correctly configured
|
||||
|
||||
---
|
||||
|
||||
## Testing Attempts
|
||||
|
||||
### 1. Android Auto APK on API 28 Emulator (emulator-5554)
|
||||
- Installed Android Auto v15.6 (x86_64 split) — **failed**: emulator is arm64-v8a, ABI mismatch
|
||||
- Installed Android Auto v16.3 (arm64-v8a split) — **succeeded**
|
||||
- Enabled developer mode by tapping Version 10× in AA settings
|
||||
- Wrote `gearhead_config.xml` directly via `adb root` to persist developer mode
|
||||
- `Start head unit server` appeared in overflow menu and was activated
|
||||
- Port 5277 confirmed listening via `/proc/net/tcp6`
|
||||
- DHU launched, connected (`[I]: connected`), then **immediately disconnected**
|
||||
- **Root cause:** DHU v2.0 (released 2022) uses protocol version 1.7; Android Auto v16.3 (2025) requires a newer protocol version. Google has not released a DHU update since 2022.
|
||||
|
||||
### 2. Android Auto APK on API 36 Play Store Emulator (emulator-5556)
|
||||
- Android Auto v16.3 installed successfully (arm64-v8a)
|
||||
- Could not enable developer mode via `adb root` — production build, root denied
|
||||
- Enabled developer mode via UI (tapped Version 10×, confirmed dialog)
|
||||
- `Start head unit server` appeared in overflow, tapped it
|
||||
- Port 5277 **never opened** — AA silently refused to start HUS
|
||||
- **Root cause:** Android Auto v16.3 requires a signed-in Google account to start the head unit server. The Play Store emulator could not complete Google sign-in due to captcha blocking on emulators.
|
||||
|
||||
### 3. Physical Lenovo Tablet (HA26DKYS, Android 15)
|
||||
- Android Auto v16.3 installed via `adb install-multiple` — succeeded
|
||||
- Radio app installed via `adb install`
|
||||
- Developer mode already enabled, "Unknown sources" already checked
|
||||
- Head unit server started manually — port 5277 confirmed listening
|
||||
- `adb forward tcp:5277 tcp:5277` established
|
||||
- DHU launched, connected, then **immediately disconnected**
|
||||
- **Root cause:** Same protocol version mismatch as attempt 1. DHU v2.0 is incompatible with Android Auto v16.3 regardless of device.
|
||||
|
||||
### 4. Head Unit Reloaded (SELF MODE) on Tablet
|
||||
- App `gb.xxy.hr` (Head Unit Reloaded) already installed on tablet
|
||||
- SELF MODE is intended to run Android Auto's car UI locally on the same device
|
||||
- App **crashed** on SELF MODE tap — likely incompatibility with Android 15 or the sideloaded AA version
|
||||
- No further investigation pursued
|
||||
|
||||
---
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **DHU v2.0 is effectively end-of-life for modern Android Auto.** Google stopped updating the DHU via SDK Manager after the 2022 release. Android Auto v16.x uses a newer protocol that DHU v2.0 cannot negotiate. The only confirmed-working approach from a [March 7, 2026 blog post](https://helw.net/2026/03/07/running-android-auto-on-an-emulator/) uses the same DHU method — meaning even the community has no working solution for newer AA versions.
|
||||
|
||||
2. **Android Auto requires a signed-in Google account to start the head unit server on modern versions.** This makes purely emulator-based testing (without Google Play sign-in) impossible for v16+.
|
||||
|
||||
3. **Android Automotive OS emulator is a different product.** AAOS emulator (available via SDK Manager) tests built-in car OS apps, not phone-projection Android Auto apps. It does not help test `MediaBrowserService` integrations.
|
||||
|
||||
4. **`dumpsys media_session` is reliable validation for MediaBrowserService.** The OS-level session dump confirms registration, state, metadata, and custom commands are all correctly exposed — which is exactly what Android Auto reads. Visual DHU testing adds UI confidence but is not required to verify the integration is correct.
|
||||
|
||||
5. **APKM bundles are ZIP archives.** APKMirror's `.apkm` files can be extracted with `unzip` and installed with `adb install-multiple`, selecting the correct architecture (`arm64_v8a`), density (`xxhdpi`), and language (`en`) splits for the target device.
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
All implementation is complete and functionally verified. Visual Android Auto UI testing requires either:
|
||||
- A physical car head unit running Android Auto
|
||||
- Google releasing a DHU update compatible with Android Auto v16+
|
||||
48
docs/plans/2026-03-18-heavy-blur-background-design.md
Normal file
48
docs/plans/2026-03-18-heavy-blur-background-design.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Design: Heavy Blur Background via Coil BlurTransformation
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The player screen's blurred album art background uses the Cloudy library at its maximum radius of 25. This is insufficient — album art with text remains legible and fine details are still visible, breaking the intended "washed out background" aesthetic.
|
||||
|
||||
## Solution
|
||||
|
||||
Replace Cloudy with a self-contained Stackblur `BlurTransformation` applied via Coil's image loading pipeline. Blur is baked into the decoded bitmap on a background thread, producing a heavily smeared result with no legible text or sharp edges.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Add `BlurTransformation.kt`
|
||||
|
||||
Create `app/src/main/java/xyz/cottongin/radio247/ui/util/BlurTransformation.kt`.
|
||||
|
||||
A ~130-line pure Kotlin + Android bitmap Stackblur implementation (no new library dependency). Accepts:
|
||||
- `radius: Int` — blur radius (25 is already very aggressive at low scale)
|
||||
- `scale: Float` — downsample factor applied before blurring (0.1f = 10% of original size)
|
||||
|
||||
### 2. Update `BlurredBackground` in `NowPlayingScreen.kt`
|
||||
|
||||
- Remove `.cloudy(radius = 25)` modifier
|
||||
- Remove `size(10, 10)` from the `ImageRequest` (scale parameter in the transformation handles downsampling)
|
||||
- Add `transformations(BlurTransformation(radius = 25, scale = 0.1f))` to the `ImageRequest`
|
||||
- Remove Cloudy import
|
||||
|
||||
### 3. Remove Cloudy dependency
|
||||
|
||||
- Remove `implementation(libs.cloudy)` from `app/build.gradle.kts`
|
||||
- Remove `cloudy` version and library entries from `gradle/libs.versions.toml`
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Value | Rationale |
|
||||
|-----------|-------|-----------|
|
||||
| `radius` | 25 | Maximum effective Stackblur radius before diminishing returns |
|
||||
| `scale` | 0.1f | Downsamples to 10% before blurring, destroying fine detail and text |
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- **No new dependency** — implementation is inlined as a project file
|
||||
- **All API levels** — pure bitmap manipulation, no RenderEffect/RenderScript
|
||||
- **Background thread** — blur computed by Coil pipeline, zero per-frame GPU cost
|
||||
- **Not animatable** — blur is baked at load time; cannot be animated at runtime (acceptable for this use case)
|
||||
291
docs/plans/2026-03-18-heavy-blur-background.md
Normal file
291
docs/plans/2026-03-18-heavy-blur-background.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Heavy Blur Background Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the Cloudy blur library with an inlined Stackblur `BlurTransformation` applied via Coil's pipeline, producing a heavily blurred background where album art text is completely illegible.
|
||||
|
||||
**Architecture:** A self-contained `BlurTransformation` class is added to the project's util package. It is applied as a Coil `ImageRequest` transformation in `BlurredBackground`, replacing the `.cloudy()` modifier. Cloudy is then removed from all dependency files.
|
||||
|
||||
**Tech Stack:** Kotlin, Jetpack Compose, Coil 3 (`io.coil-kt.coil3`), Android Bitmap APIs
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add BlurTransformation utility class
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src/main/java/xyz/cottongin/radio247/ui/util/BlurTransformation.kt`
|
||||
|
||||
**Step 1: Create the file with the Stackblur implementation**
|
||||
|
||||
```kotlin
|
||||
package xyz.cottongin.radio247.ui.util
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import coil3.size.Size
|
||||
import coil3.transform.Transformation
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class BlurTransformation(
|
||||
private val radius: Int = 25,
|
||||
private val scale: Float = 0.1f
|
||||
) : Transformation() {
|
||||
|
||||
override val cacheKey: String = "${javaClass.name}-$radius-$scale"
|
||||
|
||||
override suspend fun transform(input: Bitmap, size: Size): Bitmap =
|
||||
input.stackBlur(scale, radius) ?: input
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
return other is BlurTransformation && radius == other.radius && scale == other.scale
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = 31 * radius.hashCode() + scale.hashCode()
|
||||
}
|
||||
|
||||
private suspend fun Bitmap.stackBlur(scale: Float, radius: Int): Bitmap? =
|
||||
withContext(Dispatchers.IO) {
|
||||
var sentBitmap = this@stackBlur
|
||||
val width = (sentBitmap.width * scale).roundToInt()
|
||||
val height = (sentBitmap.height * scale).roundToInt()
|
||||
sentBitmap = Bitmap.createScaledBitmap(sentBitmap, width, height, false)
|
||||
val bitmap = sentBitmap.copy(sentBitmap.config ?: Bitmap.Config.ARGB_8888, true)
|
||||
if (radius < 1) return@withContext null
|
||||
val w = bitmap.width
|
||||
val h = bitmap.height
|
||||
val pix = IntArray(w * h)
|
||||
bitmap.getPixels(pix, 0, w, 0, 0, w, h)
|
||||
val wm = w - 1
|
||||
val hm = h - 1
|
||||
val wh = w * h
|
||||
val div = radius + radius + 1
|
||||
val r = IntArray(wh)
|
||||
val g = IntArray(wh)
|
||||
val b = IntArray(wh)
|
||||
var rsum: Int; var gsum: Int; var bsum: Int
|
||||
var x: Int; var y: Int; var i: Int; var p: Int; var yp: Int; var yi: Int
|
||||
val vmin = IntArray(w.coerceAtLeast(h))
|
||||
var divsum = div + 1 shr 1
|
||||
divsum *= divsum
|
||||
val dv = IntArray(256 * divsum) { it / divsum }
|
||||
yi = 0; var yw = 0
|
||||
val stack = Array(div) { IntArray(3) }
|
||||
var stackpointer: Int; var stackstart: Int; var sir: IntArray; var rbs: Int
|
||||
val r1 = radius + 1
|
||||
var routsum: Int; var goutsum: Int; var boutsum: Int
|
||||
var rinsum: Int; var ginsum: Int; var binsum: Int
|
||||
y = 0
|
||||
while (y < h) {
|
||||
bsum = 0; gsum = 0; rsum = 0; boutsum = 0; goutsum = 0; routsum = 0
|
||||
binsum = 0; ginsum = 0; rinsum = 0
|
||||
i = -radius
|
||||
while (i <= radius) {
|
||||
p = pix[yi + wm.coerceAtMost(i.coerceAtLeast(0))]
|
||||
sir = stack[i + radius]
|
||||
sir[0] = p and 0xff0000 shr 16; sir[1] = p and 0x00ff00 shr 8; sir[2] = p and 0x0000ff
|
||||
rbs = r1 - abs(i)
|
||||
rsum += sir[0] * rbs; gsum += sir[1] * rbs; bsum += sir[2] * rbs
|
||||
if (i > 0) { rinsum += sir[0]; ginsum += sir[1]; binsum += sir[2] }
|
||||
else { routsum += sir[0]; goutsum += sir[1]; boutsum += sir[2] }
|
||||
i++
|
||||
}
|
||||
stackpointer = radius; x = 0
|
||||
while (x < w) {
|
||||
r[yi] = dv[rsum]; g[yi] = dv[gsum]; b[yi] = dv[bsum]
|
||||
rsum -= routsum; gsum -= goutsum; bsum -= boutsum
|
||||
stackstart = stackpointer - radius + div
|
||||
sir = stack[stackstart % div]
|
||||
routsum -= sir[0]; goutsum -= sir[1]; boutsum -= sir[2]
|
||||
if (y == 0) vmin[x] = (x + radius + 1).coerceAtMost(wm)
|
||||
p = pix[yw + vmin[x]]
|
||||
sir[0] = p and 0xff0000 shr 16; sir[1] = p and 0x00ff00 shr 8; sir[2] = p and 0x0000ff
|
||||
rinsum += sir[0]; ginsum += sir[1]; binsum += sir[2]
|
||||
rsum += rinsum; gsum += ginsum; bsum += binsum
|
||||
stackpointer = (stackpointer + 1) % div
|
||||
sir = stack[stackpointer % div]
|
||||
routsum += sir[0]; goutsum += sir[1]; boutsum += sir[2]
|
||||
rinsum -= sir[0]; ginsum -= sir[1]; binsum -= sir[2]
|
||||
yi++; x++
|
||||
}
|
||||
yw += w; y++
|
||||
}
|
||||
x = 0
|
||||
while (x < w) {
|
||||
bsum = 0; gsum = 0; rsum = 0; boutsum = 0; goutsum = 0; routsum = 0
|
||||
binsum = 0; ginsum = 0; rinsum = 0
|
||||
yp = -radius * w; i = -radius
|
||||
while (i <= radius) {
|
||||
yi = 0.coerceAtLeast(yp) + x
|
||||
sir = stack[i + radius]
|
||||
sir[0] = r[yi]; sir[1] = g[yi]; sir[2] = b[yi]
|
||||
rbs = r1 - abs(i)
|
||||
rsum += r[yi] * rbs; gsum += g[yi] * rbs; bsum += b[yi] * rbs
|
||||
if (i > 0) { rinsum += sir[0]; ginsum += sir[1]; binsum += sir[2] }
|
||||
else { routsum += sir[0]; goutsum += sir[1]; boutsum += sir[2] }
|
||||
if (i < hm) yp += w
|
||||
i++
|
||||
}
|
||||
yi = x; stackpointer = radius; y = 0
|
||||
while (y < h) {
|
||||
pix[yi] = -0x1000000 and pix[yi] or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum]
|
||||
rsum -= routsum; gsum -= goutsum; bsum -= boutsum
|
||||
stackstart = stackpointer - radius + div
|
||||
sir = stack[stackstart % div]
|
||||
routsum -= sir[0]; goutsum -= sir[1]; boutsum -= sir[2]
|
||||
if (x == 0) vmin[y] = (y + r1).coerceAtMost(hm) * w
|
||||
p = x + vmin[y]
|
||||
sir[0] = r[p]; sir[1] = g[p]; sir[2] = b[p]
|
||||
rinsum += sir[0]; ginsum += sir[1]; binsum += sir[2]
|
||||
rsum += rinsum; gsum += ginsum; bsum += binsum
|
||||
stackpointer = (stackpointer + 1) % div
|
||||
sir = stack[stackpointer]
|
||||
routsum += sir[0]; goutsum += sir[1]; boutsum += sir[2]
|
||||
rinsum -= sir[0]; ginsum -= sir[1]; binsum -= sir[2]
|
||||
yi += w; y++
|
||||
}
|
||||
x++
|
||||
}
|
||||
bitmap.setPixels(pix, 0, w, 0, 0, w, h)
|
||||
sentBitmap.recycle()
|
||||
bitmap
|
||||
}
|
||||
```
|
||||
|
||||
> **Note on Coil 3 API:** Coil 3 uses `coil3.transform.Transformation` (abstract class, not interface) and `coil3.size.Size`. The `transform()` method signature is `suspend fun transform(input: Bitmap, size: Size): Bitmap`. Verify these imports compile — if `Transformation` is an interface in the version in use, remove the `()` after `Transformation`.
|
||||
|
||||
**Step 2: Verify the file compiles**
|
||||
|
||||
```bash
|
||||
cd /path/to/Android-247-Radio
|
||||
./gradlew :app:compileDebugKotlin 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL (or only pre-existing warnings, no new errors)
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add app/src/main/java/xyz/cottongin/radio247/ui/util/BlurTransformation.kt
|
||||
git commit -m "feat: add Stackblur BlurTransformation utility"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update BlurredBackground to use BlurTransformation
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt:359-388`
|
||||
|
||||
**Step 1: Update the `BlurredBackground` composable**
|
||||
|
||||
Find this block (lines ~368–378):
|
||||
|
||||
```kotlin
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(artworkUrl)
|
||||
.size(Size(10, 10))
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.cloudy(radius = 25)
|
||||
)
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```kotlin
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(artworkUrl)
|
||||
.transformations(BlurTransformation(radius = 25, scale = 0.1f))
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Update imports in `NowPlayingScreen.kt`**
|
||||
|
||||
Remove:
|
||||
```kotlin
|
||||
import com.skydoves.cloudy.cloudy
|
||||
```
|
||||
|
||||
Add (if not auto-resolved by IDE):
|
||||
```kotlin
|
||||
import xyz.cottongin.radio247.ui.util.BlurTransformation
|
||||
```
|
||||
|
||||
Also remove the `coil.size.Size` import if it was only used for the `size(10, 10)` call and is no longer referenced. Check for any remaining `Size` usage in the file first.
|
||||
|
||||
**Step 3: Verify the file compiles**
|
||||
|
||||
```bash
|
||||
./gradlew :app:compileDebugKotlin 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/src/main/java/xyz/cottongin/radio247/ui/screens/nowplaying/NowPlayingScreen.kt
|
||||
git commit -m "feat: replace cloudy blur with BlurTransformation on player background"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Remove Cloudy dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/build.gradle.kts`
|
||||
- Modify: `gradle/libs.versions.toml`
|
||||
|
||||
**Step 1: Remove from `app/build.gradle.kts`**
|
||||
|
||||
Remove this line:
|
||||
```kotlin
|
||||
implementation(libs.cloudy)
|
||||
```
|
||||
|
||||
**Step 2: Remove from `gradle/libs.versions.toml`**
|
||||
|
||||
Remove the version entry:
|
||||
```toml
|
||||
cloudy = "0.2.7"
|
||||
```
|
||||
|
||||
Remove the library entry:
|
||||
```toml
|
||||
cloudy = { group = "com.github.skydoves", name = "cloudy", version.ref = "cloudy" }
|
||||
```
|
||||
|
||||
**Step 3: Sync and verify full build**
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleDebug 2>&1 | tail -30
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/build.gradle.kts gradle/libs.versions.toml
|
||||
git commit -m "chore: remove Cloudy blur library dependency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After all tasks complete, install on device/emulator and navigate to the Now Playing screen. Album art text should be completely illegible and no sharp pixel edges should be visible in the background.
|
||||
|
||||
If the blur is still not strong enough, increase `scale` down (e.g. `0.05f`) or increase `radius` (e.g. `radius = 50`) in `BlurTransformation(radius = 25, scale = 0.1f)`.
|
||||
BIN
docs/screenshots/01-station-list.jpg
Normal file
BIN
docs/screenshots/01-station-list.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 275 KiB |
BIN
docs/screenshots/02-now-playing-art.jpg
Normal file
BIN
docs/screenshots/02-now-playing-art.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 256 KiB |
BIN
docs/screenshots/03-station-list-miniplayer.jpg
Normal file
BIN
docs/screenshots/03-station-list-miniplayer.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 277 KiB |
BIN
docs/screenshots/04-now-playing-station-art.jpg
Normal file
BIN
docs/screenshots/04-now-playing-station-art.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
@@ -14,7 +14,6 @@ junit = "4.13.2"
|
||||
mockk = "1.13.16"
|
||||
turbine = "1.2.0"
|
||||
coil = "3.1.0"
|
||||
cloudy = "0.2.7"
|
||||
|
||||
[libraries]
|
||||
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
|
||||
@@ -48,7 +47,6 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine
|
||||
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" }
|
||||
json = { group = "org.json", name = "json", version = "20240303" }
|
||||
cloudy = { group = "com.github.skydoves", name = "cloudy", version.ref = "cloudy" }
|
||||
palette = { group = "androidx.palette", name = "palette-ktx", version = "1.0.0" }
|
||||
|
||||
[plugins]
|
||||
|
||||
Reference in New Issue
Block a user