Initial MVP

This commit is contained in:
2026-07-27 00:16:46 -04:00
commit 5930630b2e
44 changed files with 4381 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+20
View File
@@ -0,0 +1,20 @@
# Gradle
.gradle/
build/
# Android
*.apk
*.aab
local.properties
# Nix
result
result-*
# IDE
.idea/
*.iml
.kotlin/
# Environment mappings
.direnv/
+108
View File
@@ -0,0 +1,108 @@
# Music Remote
A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and
tested with **Nix**.
Jetpack Compose *is* the modern Android UI framework — declarative Kotlin UI that has
replaced the old XML layouts. Material 3 is Google's current design system, wired up here
with dynamic (wallpaper-based) color on Android 12+.
## Layout
```
flake.nix Nix entrypoint: dev shells, SDK, package, emulator
package.nix Reproducible APK build (nixpkgs-style derivation)
deps.json Lockfile of Gradle/Maven deps (generated — see below)
settings.gradle.kts Gradle project + repositories
build.gradle.kts Root build script (declares plugin versions)
gradle/libs.versions.toml Version catalog: all dependency/plugin versions
app/
build.gradle.kts The :app module (Android + Compose config)
src/main/
AndroidManifest.xml App/activity declaration
kotlin/ca/ksamad/musicremote/
MainActivity.kt Entry activity; sets the Compose content
Theme.kt Material 3 theme (dynamic color)
res/values/ strings.xml, themes.xml
src/test/kotlin/... A plain JVM unit test
```
## Everyday development (recommended)
Use the dev shell — it puts a JDK, Gradle, and the Android SDK on your PATH with the
right environment variables set:
```sh
nix develop
gradle assembleDebug # build the debug APK -> app/build/outputs/apk/debug/
gradle testDebugUnitTest # run unit tests
gradle tasks # see everything available
```
The debug APK is unsigned but installable on a device/emulator:
```sh
adb install app/build/outputs/apk/debug/app-debug.apk
```
### Android Studio
For the GUI editor, Compose previews, and device manager:
```sh
nix develop .#studio
android-studio
```
Open this folder as an existing project. (Android Studio manages its own SDK; the
command-line flow above is fully independent of it.)
### Emulator
```sh
nix run .#emulate
```
## Reproducible build with Nix
```sh
nix build # -> ./result/music-remote.apk
```
Unlike `gradle assembleDebug`, this build runs **fully offline**: every Gradle/Maven
dependency is pinned by hash in `deps.json`, and Nix supplies the Android SDK. It also
runs the unit tests (`testDebugUnitTest`) as its check phase, so a green `nix build` means
both "it assembles" and "tests pass". This is what you'd use in CI for a reproducible
artifact.
> Note: `package.nix` and `app/build.gradle.kts` contain a few small workarounds, active
> **only** during dependency capture (gated on the `IN_GRADLE_UPDATE_DEPS` env var), that
> stop Nix's "resolve every configuration" sweep from tripping over internal AGP/Kotlin
> configurations. They don't affect the dev shell or the actual build. See the comments in
> `app/build.gradle.kts` for the details.
### Regenerating `deps.json`
Any time you change a dependency or plugin version (i.e. edit
`gradle/libs.versions.toml` or a `build.gradle.kts`), regenerate the lockfile:
```sh
nix build .#default.mitmCache.updateScript
./result # runs the build under a proxy, rewrites ./deps.json
```
Then commit the updated `deps.json`. (It works by recording Gradle's dependency
downloads through a man-in-the-middle proxy and storing their hashes — see
`package.nix`.)
## Docs
- [`docs/device-testing.md`](docs/device-testing.md) — build & install onto a
real Android device/DAP over USB (and the NixOS `adb` notes).
- [`docs/TODO.md`](docs/TODO.md) — near-term roadmap / known bugs.
## Version notes
Pinned in `gradle/libs.versions.toml`: AGP 8.7.3, Kotlin 2.0.21, Compose BOM 2024.10.01,
compiled against `compileSdk` 35, `minSdk` 24. Nix supplies Gradle 8.14. If you bump
these, keep AGP/Kotlin/Gradle mutually compatible and regenerate `deps.json`.
+112
View File
@@ -0,0 +1,112 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "ca.ksamad.musicremote"
compileSdk = 35
// Pin the build-tools to exactly what Nix supplies. Without this, AGP picks
// its own default version and tries to auto-install it into the read-only
// Nix SDK, which fails. Keep this in sync with flake.nix's buildToolsVersion.
buildToolsVersion = "35.0.0"
defaultConfig {
applicationId = "ca.ksamad.musicremote"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
testOptions {
unitTests.all {
// Surface `println`/stdout from unit tests in the Gradle console.
// Handy for the gated MPD integration test (see MpdServerIntegrationTest).
it.testLogging {
showStandardStreams = true
}
}
}
}
// --- Nix reproducible-build support -----------------------------------------
// The Nix dependency-capture step (`nixDownloadDeps`) resolves *every* resolvable
// configuration at once to record what Gradle downloads. Several AGP/Kotlin
// configurations aren't meant to be resolved that way and break the sweep:
// * `*DependenciesMetadata` (Kotlin) resolve with no version under a BOM.
// * the unit-/android-test classpaths carry a `:app -> :app` self-dependency
// that is ambiguous without AGP's own artifact-view based resolution.
// These tweaks apply ONLY during that capture step, detected via the
// IN_GRADLE_UPDATE_DEPS env var the update script sets. Ordinary builds — in the
// dev shell or the real `nix build` (assembleDebug) — see none of this and use
// 100% standard Android configuration, including their own test resolution.
if (System.getenv("IN_GRADLE_UPDATE_DEPS") == "1") {
// Drop the test components entirely for the sweep, so their compile/runtime
// classpaths (which carry the ambiguous `:app -> :app` self-dependency) are
// never created. Removing the component is clean; merely marking those
// classpaths non-resolvable instead makes the Kotlin plugin fail.
androidComponents {
beforeVariants(selector().all()) { variantBuilder ->
variantBuilder.enableAndroidTest = false
variantBuilder.enableUnitTest = false
}
}
// Kotlin's `*DependenciesMetadata` configs resolve with no version under a
// BOM; they pull nothing the real classpaths don't, so skip them.
configurations.configureEach {
if (name.endsWith("DependenciesMetadata")) {
isCanBeResolved = false
}
}
// With the unit-test component gone, still record the test-only artifacts
// (JUnit) via a clean configuration that has no project self-dependency, so
// the captured lockfile is complete enough for `testDebugUnitTest` to run
// offline during `nix build`.
val nixCaptureTestDeps = configurations.create("nixCaptureTestDeps")
dependencies.addProvider(nixCaptureTestDeps.name, libs.junit)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.datastore.preferences)
// The Compose BOM aligns every Compose artifact to one tested version set,
// so the individual Compose deps below are declared without versions.
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
debugImplementation(libs.androidx.ui.tooling)
testImplementation(libs.junit)
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Needed to open a TCP socket to the MPD server over the network. -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MusicRemote">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,34 @@
package ca.ksamad.musicremote
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import ca.ksamad.musicremote.ui.MusicRemoteApp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MusicRemoteTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
color = MaterialTheme.colorScheme.background,
) {
MusicRemoteApp()
}
}
}
}
}
}
@@ -0,0 +1,39 @@
package ca.ksamad.musicremote
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/**
* Material 3 theme for the app.
*
* On Android 12+ it uses "dynamic color" (the palette derived from the user's
* wallpaper); on older versions it falls back to a default light/dark scheme.
*/
@Composable
fun MusicRemoteTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content,
)
}
@@ -0,0 +1,12 @@
package ca.ksamad.musicremote.data
/** The persisted MPD server the app connects to. */
data class ConnectionSettings(
val host: String,
val port: Int,
) {
companion object {
/** First-run defaults, used until the user connects at least once. */
val DEFAULT = ConnectionSettings(host = "192.168.2.148", port = 6600)
}
}
@@ -0,0 +1,49 @@
package ca.ksamad.musicremote.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
// A single process-wide DataStore instance, tied to the application context via
// this property delegate (the recommended pattern — creating more than one
// DataStore for the same file throws).
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "connection_settings")
/**
* Reads and writes the persisted [ConnectionSettings] using Preferences
* DataStore. Reads come back as a [Flow] that emits on every change; the write
* is a `suspend` transaction.
*/
class SettingsRepository(private val context: Context) {
private object Keys {
val HOST = stringPreferencesKey("host")
val PORT = intPreferencesKey("port")
}
/**
* The saved settings, or `null` if the user has never connected (no host
* persisted yet). Callers use the null case to show first-run UI / decide
* whether to auto-connect.
*/
val settingsOrNull: Flow<ConnectionSettings?> = context.dataStore.data.map { prefs ->
val host = prefs[Keys.HOST] ?: return@map null
ConnectionSettings(host = host, port = prefs[Keys.PORT] ?: ConnectionSettings.DEFAULT.port)
}
/** Same as [settingsOrNull] but falling back to [ConnectionSettings.DEFAULT] for form prefill. */
val settings: Flow<ConnectionSettings> = settingsOrNull.map { it ?: ConnectionSettings.DEFAULT }
suspend fun save(settings: ConnectionSettings) {
context.dataStore.edit { prefs ->
prefs[Keys.HOST] = settings.host
prefs[Keys.PORT] = settings.port
}
}
}
@@ -0,0 +1,245 @@
package ca.ksamad.musicremote.mpd
import ca.ksamad.musicremote.mpd.model.MpdSong
import ca.ksamad.musicremote.mpd.model.MpdStatus
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.IOException
/**
* The high-level, coroutine-driven MPD client. It owns **two** connections, the
* pattern MALP and other real clients use:
*
* - a **command** connection, guarded by a [Mutex], for request/response
* commands (play, setvol, playlistinfo, …);
* - an **idle** connection parked in the blocking `idle` command, so the server
* can push change notifications. Each `changed:` event triggers a refresh on
* the command connection, which flows out through [status]/[currentSong].
*
* Why two connections: `idle` blocks its connection indefinitely, so it can't
* also carry commands. Splitting them lets the UI stay live (idle) while still
* issuing actions (command) without racing on one socket.
*
* All blocking socket I/O is dispatched to [ioDispatcher]. Observe the exposed
* [StateFlow]s from the UI; call [connect]/[disconnect] to manage the link.
*
* A [connectionFactory] is injectable so tests can supply fake transports; it
* defaults to a real TCP [MpdConnection.connect].
*/
class MpdClient(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val connectionFactory: (host: String, port: Int, readTimeoutMs: Int) -> MpdConnection =
{ host, port, readTimeoutMs -> MpdConnection.connect(host, port, readTimeoutMs = readTimeoutMs) },
) {
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
private val commandMutex = Mutex()
private var commandConn: MpdConnection? = null
private var idleConn: MpdConnection? = null
private var idleJob: Job? = null
/**
* True while a [disconnect] is in progress, so the idle loop can tell an
* intentional socket close apart from a real connection drop.
*/
@Volatile
private var shuttingDown = false
private val _connectionState = MutableStateFlow<MpdConnectionState>(MpdConnectionState.Disconnected)
val connectionState = _connectionState.asStateFlow()
private val _status = MutableStateFlow<MpdStatus?>(null)
val status = _status.asStateFlow()
private val _currentSong = MutableStateFlow<MpdSong?>(null)
val currentSong = _currentSong.asStateFlow()
// --- Lifecycle ----------------------------------------------------------
/**
* Open both connections, authenticate, prime the initial state, and start
* the idle loop. Safe to await; failures land in [connectionState] as
* [MpdConnectionState.Error] and the call throws.
*/
suspend fun connect(host: String, port: Int = MpdConnection.DEFAULT_PORT, password: String? = null) {
disconnect() // ensure a clean slate if re-connecting
shuttingDown = false
_connectionState.value = MpdConnectionState.Connecting
try {
withContext(ioDispatcher) {
// Command connection: a finite read timeout so a wedged server
// surfaces as an error instead of hanging a UI action forever.
val command = connectionFactory(host, port, COMMAND_READ_TIMEOUT_MS)
// Idle connection: no read timeout — it must park indefinitely.
val idle = connectionFactory(host, port, 0)
if (password != null) {
command.execute(MpdCommands.password(password))
idle.execute(MpdCommands.password(password))
}
commandConn = command
idleConn = idle
}
refresh()
_connectionState.value = MpdConnectionState.Connected
startIdleLoop()
} catch (e: IOException) {
failAndClose(e.message ?: "connection failed")
throw e
}
}
/** Tear down the idle loop and both connections; returns to [MpdConnectionState.Disconnected]. */
suspend fun disconnect() {
shuttingDown = true
// Closing the idle socket unblocks the loop's parked `idle` read.
withContext(ioDispatcher) {
idleConn?.close()
commandConn?.close()
}
idleJob?.cancelAndJoin()
idleJob = null
idleConn = null
commandConn = null
if (_connectionState.value !is MpdConnectionState.Error) {
_connectionState.value = MpdConnectionState.Disconnected
}
shuttingDown = false
}
/**
* Non-suspending teardown for owner destruction (e.g. `ViewModel.onCleared`).
* Cancels the internal scope and drops the sockets without waiting.
*/
fun shutdown() {
shuttingDown = true
try {
idleConn?.close()
} catch (_: IOException) {
}
try {
commandConn?.close()
} catch (_: IOException) {
}
idleConn = null
commandConn = null
scope.cancel()
_connectionState.value = MpdConnectionState.Disconnected
}
// --- Commands -----------------------------------------------------------
//
// These don't optimistically update the flows: the change they cause makes
// the server emit an idle event, which refreshes state through the idle
// loop. That keeps the app in lockstep with the server (and with other
// clients) rather than guessing.
suspend fun play() = run(MpdCommands.play())
suspend fun playPos(pos: Int) = run(MpdCommands.playPos(pos))
suspend fun playId(songId: Int) = run(MpdCommands.playId(songId))
suspend fun stop() = run(MpdCommands.stop())
suspend fun next() = run(MpdCommands.next())
suspend fun previous() = run(MpdCommands.previous())
suspend fun pause(paused: Boolean) = run(MpdCommands.pause(paused))
/** Flip play/pause based on the latest known [status]. No-op if state is unknown. */
suspend fun togglePause() {
val playing = _status.value?.state == ca.ksamad.musicremote.mpd.model.PlayerState.PLAY
run(MpdCommands.pause(playing))
}
suspend fun seekCurrent(seconds: Double) = run(MpdCommands.seekCurrent(seconds))
suspend fun setVolume(volume: Int) = run(MpdCommands.setVolume(volume))
suspend fun setRepeat(on: Boolean) = run(MpdCommands.repeat(on))
suspend fun setRandom(on: Boolean) = run(MpdCommands.random(on))
suspend fun setSingle(on: Boolean) = run(MpdCommands.single(on))
suspend fun setConsume(on: Boolean) = run(MpdCommands.consume(on))
/** Fetch the current play queue. */
suspend fun queue(): List<MpdSong> = withCommand { conn ->
conn.execute(MpdCommands.playlistInfo()).split().mapNotNull { MpdSong.from(it) }
}
// --- Internals ----------------------------------------------------------
/** Run a fire-and-forget command whose response we don't need. */
private suspend fun run(commandLine: String) {
withCommand { it.execute(commandLine) }
}
/** Serialize access to the command connection and run [block] on the I/O dispatcher. */
private suspend fun <T> withCommand(block: (MpdConnection) -> T): T =
withContext(ioDispatcher) {
commandMutex.withLock {
val conn = commandConn
?: throw MpdConnectionException("not connected")
block(conn)
}
}
/** Re-read `status` and `currentsong` into the flows. */
private suspend fun refresh() {
val (status, song) = withCommand { conn ->
val status = MpdStatus.from(conn.execute(MpdCommands.status()).toMap())
val song = MpdSong.from(conn.execute(MpdCommands.currentSong()).toMap())
status to song
}
_status.value = status
_currentSong.value = song
}
private fun startIdleLoop() {
val idle = idleConn ?: return
idleJob = scope.launch {
try {
while (isActive) {
// Blocks here until the server reports a change (or we close
// the socket during disconnect, which throws below).
val changed = withContext(ioDispatcher) {
idle.execute(MpdCommands.idle()).getAll("changed")
}
if (changed.isEmpty()) continue
// Any of these subsystems affect what we currently show.
if (changed.any { it in REFRESHING_SUBSYSTEMS }) {
refresh()
}
}
} catch (e: IOException) {
if (!shuttingDown) failAndClose(e.message ?: "idle connection lost")
}
}
}
/** Record an error state and drop the connections (best-effort). */
private fun failAndClose(message: String) {
_connectionState.value = MpdConnectionState.Error(message)
try {
idleConn?.close()
} catch (_: IOException) {
}
try {
commandConn?.close()
} catch (_: IOException) {
}
idleConn = null
commandConn = null
}
companion object {
private const val COMMAND_READ_TIMEOUT_MS = 10_000
/** Idle subsystems that change something [status]/[currentSong] reflects. */
private val REFRESHING_SUBSYSTEMS = setOf("player", "mixer", "options", "playlist")
}
}
@@ -0,0 +1,69 @@
package ca.ksamad.musicremote.mpd
/**
* Typed builders for the command lines we send. These return the assembled,
* properly-quoted string (no newline) that gets handed to
* [MpdConnection.execute]; keeping them pure makes both the argument quoting and
* the exact wire form unit-testable without any I/O.
*
* This is deliberately a small, growing subset — the transport controls, option
* toggles, and status queries a remote UI needs first. Database/queue/playlist
* builders get added as those features land.
*/
object MpdCommands {
// --- Status queries -----------------------------------------------------
fun status() = MpdProtocol.command("status")
fun currentSong() = MpdProtocol.command("currentsong")
fun stats() = MpdProtocol.command("stats")
/**
* Block until one of [subsystems] changes (all of them if none given). Only
* legal on a connection dedicated to idling — never inside a command list.
*/
fun idle(vararg subsystems: String) =
if (subsystems.isEmpty()) MpdProtocol.command("idle")
else MpdProtocol.command("idle", *subsystems)
fun noidle() = MpdProtocol.command("noidle")
fun ping() = MpdProtocol.command("ping")
// --- Authentication -----------------------------------------------------
fun password(password: String) = MpdProtocol.command("password", password)
// --- Transport ----------------------------------------------------------
fun play() = MpdProtocol.command("play")
fun playPos(pos: Int) = MpdProtocol.command("play", pos.toString())
fun playId(songId: Int) = MpdProtocol.command("playid", songId.toString())
fun stop() = MpdProtocol.command("stop")
fun next() = MpdProtocol.command("next")
fun previous() = MpdProtocol.command("previous")
/** `pause 1`/`pause 0`; with no state MPD toggles, but we send it explicitly. */
fun pause(paused: Boolean) =
MpdProtocol.command("pause", if (paused) "1" else "0")
/** Seek to [seconds] within the currently playing song. */
fun seekCurrent(seconds: Double) =
MpdProtocol.command("seekcur", seconds.toString())
// --- Options / mixer ----------------------------------------------------
fun setVolume(volume: Int) =
MpdProtocol.command("setvol", volume.coerceIn(0, 100).toString())
fun repeat(on: Boolean) = MpdProtocol.command("repeat", if (on) "1" else "0")
fun random(on: Boolean) = MpdProtocol.command("random", if (on) "1" else "0")
fun single(on: Boolean) = MpdProtocol.command("single", if (on) "1" else "0")
fun consume(on: Boolean) = MpdProtocol.command("consume", if (on) "1" else "0")
// --- Queue --------------------------------------------------------------
fun playlistInfo() = MpdProtocol.command("playlistinfo")
fun clear() = MpdProtocol.command("clear")
fun add(uri: String) = MpdProtocol.command("add", uri)
fun deleteId(songId: Int) = MpdProtocol.command("deleteid", songId.toString())
}
@@ -0,0 +1,172 @@
package ca.ksamad.musicremote.mpd
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.ByteArrayOutputStream
import java.io.Closeable
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.Socket
/**
* A single synchronous MPD connection: the raw text protocol over a byte
* stream. One command is written, one response is read back; calls are **not**
* thread-safe and must be serialized by the caller (a higher-level client will
* own a [MpdConnection] behind a mutex, and a second one parked in `idle`).
*
* The transport is an [InputStream]/[OutputStream] pair rather than a [Socket]
* so the protocol can be exercised against in-memory streams in tests. Use
* [connect] for a real TCP connection.
*
* Reading is done at the byte level (not via a [java.io.Reader]) because
* responses can interleave UTF-8 text lines with raw binary payloads, and a
* buffered character reader would happily swallow bytes past a line boundary.
*/
class MpdConnection(
private val input: InputStream,
private val output: OutputStream,
private val closer: Closeable? = null,
) : Closeable {
/** Protocol version from the greeting, e.g. `0.23.5`. Null until [handshake]. */
var protocolVersion: String? = null
private set
/**
* Read and validate the server greeting (`OK MPD <version>`). Must be called
* exactly once, before any command. Returns the protocol version.
*/
fun handshake(): String {
val line = readLine()
?: throw MpdConnectionException("connection closed before greeting")
if (!line.startsWith(MpdProtocol.GREETING_PREFIX)) {
throw MpdConnectionException("unexpected greeting: $line")
}
val version = line.removePrefix(MpdProtocol.GREETING_PREFIX).trim()
protocolVersion = version
return version
}
/**
* Send one command line (name + already-assembled string, no newline) and
* read its response up to the terminating `OK`.
*
* @throws MpdAckException if the server replied `ACK …`.
* @throws MpdConnectionException on EOF or a malformed response.
*/
fun execute(commandLine: String): MpdResponse {
write(commandLine)
return readResponse()
}
/** Convenience: [MpdProtocol.command] + [execute]. */
fun execute(name: String, vararg args: String): MpdResponse =
execute(MpdProtocol.command(name, *args))
private fun write(commandLine: String) {
output.write(commandLine.toByteArray(Charsets.UTF_8))
output.write('\n'.code)
output.flush()
}
private fun readResponse(): MpdResponse {
val values = ArrayList<Pair<String, String>>()
var binary: ByteArray? = null
while (true) {
val line = readLine()
?: throw MpdConnectionException("connection closed mid-response")
when {
line == MpdProtocol.OK -> return MpdResponse(values, binary)
line.startsWith("ACK ") ->
throw MpdAckException.parse(line)
?: MpdConnectionException("malformed ACK: $line")
else -> {
val sep = line.indexOf(": ")
if (sep < 0) continue // tolerate stray lines rather than fail
val key = line.substring(0, sep)
val value = line.substring(sep + 2)
if (key == "binary") {
binary = readBinary(
value.toIntOrNull()
?: throw MpdConnectionException("bad binary size: $value")
)
} else {
values.add(key to value)
}
}
}
}
}
/** Read raw bytes until `\n` (exclusive), decode UTF-8. Null on immediate EOF. */
private fun readLine(): String? {
val buf = ByteArrayOutputStream(64)
while (true) {
val b = input.read()
when (b) {
-1 -> return if (buf.size() == 0) null else buf.toString("UTF-8")
'\n'.code -> return buf.toString("UTF-8")
else -> buf.write(b)
}
}
}
/** Read exactly [size] bytes of a binary payload, then consume the trailing `\n`. */
private fun readBinary(size: Int): ByteArray {
val data = ByteArray(size)
var off = 0
while (off < size) {
val n = input.read(data, off, size - off)
if (n == -1) throw MpdConnectionException("EOF after ${off}/$size binary bytes")
off += n
}
input.read() // trailing newline that follows the binary block
return data
}
override fun close() {
// Best-effort: closing the socket tears down both streams.
try {
closer?.close() ?: output.close()
} catch (_: IOException) {
}
}
companion object {
const val DEFAULT_PORT = 6600
/**
* Open a TCP connection to an MPD server and complete the greeting
* handshake. Blocking — call off the main thread.
*
* @param connectTimeoutMs socket connect timeout.
* @param readTimeoutMs `SO_TIMEOUT`; 0 means block forever (needed for
* the `idle` connection, which parks indefinitely).
*/
fun connect(
host: String,
port: Int = DEFAULT_PORT,
connectTimeoutMs: Int = 5_000,
readTimeoutMs: Int = 0,
): MpdConnection {
val socket = Socket()
try {
socket.connect(InetSocketAddress(host, port), connectTimeoutMs)
socket.soTimeout = readTimeoutMs
socket.tcpNoDelay = true
val conn = MpdConnection(
input = BufferedInputStream(socket.getInputStream()),
output = BufferedOutputStream(socket.getOutputStream()),
closer = socket,
)
conn.handshake()
return conn
} catch (e: IOException) {
socket.close()
throw MpdConnectionException("failed to connect to $host:$port", e)
}
}
}
}
@@ -0,0 +1,16 @@
package ca.ksamad.musicremote.mpd
/** Lifecycle of an [MpdClient]'s link to a server, surfaced as observable state. */
sealed interface MpdConnectionState {
/** No connection; the initial and post-[MpdClient.disconnect] state. */
data object Disconnected : MpdConnectionState
/** A connection attempt is in flight. */
data object Connecting : MpdConnectionState
/** Both the command and idle connections are up and the idle loop is running. */
data object Connected : MpdConnectionState
/** The connection dropped or failed; [message] describes why. */
data class Error(val message: String) : MpdConnectionState
}
@@ -0,0 +1,72 @@
package ca.ksamad.musicremote.mpd
import java.io.IOException
/** Base type for every failure the MPD layer can raise. */
sealed class MpdException(message: String, cause: Throwable? = null) :
IOException(message, cause)
/**
* A transport-level problem: the socket closed, the greeting was malformed, an
* EOF arrived mid-response, etc. These are the failures that mean "the
* connection can no longer be trusted", as opposed to a command the server
* simply rejected.
*/
class MpdConnectionException(message: String, cause: Throwable? = null) :
MpdException(message, cause)
/**
* The server understood us but refused the command, i.e. it replied with an
* `ACK` line. The wire format is:
*
* ```
* ACK [error@command_listNum] {current_command} message_text
* ```
*
* where `error` is one of MPD's numeric `ACK_ERROR_*` codes (see [Code]),
* `command_listNum` is the 0-based offset of the failing command inside a
* command list (0 for a bare command), and `current_command` names it.
*/
class MpdAckException(
val code: Int,
val commandListNum: Int,
val command: String,
val serverMessage: String,
) : MpdException("ACK [$code@$commandListNum] {$command} $serverMessage") {
/** MPD's `ACK_ERROR_*` constants, for callers that want to branch on them. */
object Code {
const val NOT_LIST = 1
const val ARG = 2
const val PASSWORD = 3
const val PERMISSION = 4
const val UNKNOWN = 5
const val NO_EXIST = 50
const val PLAYLIST_MAX = 51
const val SYSTEM = 52
const val PLAYLIST_LOAD = 53
const val UPDATE_ALREADY = 54
const val PLAYER_SYNC = 55
const val EXIST = 56
}
companion object {
// ACK [2@1] {play} Bad song index
private val PATTERN = Regex("""ACK \[(\d+)@(\d+)] \{([^}]*)} ?(.*)""")
/**
* Parse a raw `ACK …` response line. Returns `null` if [line] is not a
* well-formed ACK, so the caller can decide how to treat garbage.
*/
fun parse(line: String): MpdAckException? {
val m = PATTERN.matchEntire(line) ?: return null
val (code, listNum, command, message) = m.destructured
return MpdAckException(
code = code.toInt(),
commandListNum = listNum.toInt(),
command = command,
serverMessage = message,
)
}
}
}
@@ -0,0 +1,52 @@
package ca.ksamad.musicremote.mpd
/**
* Pure, connection-independent helpers for speaking the MPD text protocol:
* argument quoting and command-string assembly. Kept separate from
* [MpdConnection] so it can be unit-tested without any I/O.
*/
object MpdProtocol {
/** Every server greeting starts with this, followed by the protocol version. */
const val GREETING_PREFIX = "OK MPD "
/** Success terminator for a command's response. */
const val OK = "OK"
/** Per-command terminator inside a `command_list_ok_begin` list. */
const val LIST_OK = "list_OK"
/**
* Quote a single command argument. MPD's tokenizer treats a bare token as
* ending at the next whitespace, so any argument is wrapped in double quotes
* with embedded `"` and `\` backslash-escaped. Always quoting (even numbers
* and empty strings) is accepted by the server and keeps callers from having
* to reason about which values are "safe".
*/
fun quote(arg: String): String {
val sb = StringBuilder(arg.length + 2)
sb.append('"')
for (c in arg) {
if (c == '"' || c == '\\') sb.append('\\')
sb.append(c)
}
sb.append('"')
return sb.toString()
}
/**
* Assemble a full command line (without the trailing newline) from a command
* name and its arguments. The name is emitted verbatim; every argument is
* [quote]d.
*/
fun command(name: String, vararg args: String): String {
if (args.isEmpty()) return name
return buildString {
append(name)
for (a in args) {
append(' ')
append(quote(a))
}
}
}
}
@@ -0,0 +1,53 @@
package ca.ksamad.musicremote.mpd
/**
* The parsed result of a single successful command: the ordered `key: value`
* lines the server sent before its `OK`, plus an optional [binary] payload for
* commands like `albumart`/`readpicture`.
*
* Order is preserved because several commands (e.g. `playlistinfo`, `lsinfo`)
* return repeated blocks that are only separable by position — a new object
* begins each time a delimiting key such as `file` reappears. Use [split] to
* chop such a response into per-object maps.
*/
class MpdResponse(
val values: List<Pair<String, String>>,
val binary: ByteArray? = null,
) {
/** First value for [key], or `null` if absent. */
operator fun get(key: String): String? =
values.firstOrNull { it.first == key }?.second
/** All values for [key], in order. */
fun getAll(key: String): List<String> =
values.filter { it.first == key }.map { it.second }
/**
* Flatten to a map of first-seen values. Safe for commands whose keys are
* unique (`status`, `currentsong`, `stats`); lossy for repeated blocks —
* use [split] there instead.
*/
fun toMap(): Map<String, String> {
val out = LinkedHashMap<String, String>(values.size)
for ((k, v) in values) out.putIfAbsent(k, v)
return out
}
/**
* Split a multi-object response into one map per object. A new object starts
* at every occurrence of [delimiter] (default `file`, the first key MPD emits
* for a song/file entry). Lines before the first delimiter are ignored.
*/
fun split(delimiter: String = "file"): List<Map<String, String>> {
val result = ArrayList<Map<String, String>>()
var current: LinkedHashMap<String, String>? = null
for ((k, v) in values) {
if (k == delimiter) {
current = LinkedHashMap()
result.add(current)
}
current?.putIfAbsent(k, v)
}
return result
}
}
@@ -0,0 +1,51 @@
package ca.ksamad.musicremote.mpd.model
/**
* A song/file entry, parsed from the metadata block MPD emits for `currentsong`,
* `playlistinfo`, `find`, `lsinfo`, etc. [uri] (the `file` key) is the only
* required field; every tag is optional because the server only sends tags the
* file actually has.
*
* Tags are kept as raw strings — `track`/`disc` can carry values like `"3/12"`,
* and normalising them is a display concern left to the UI layer.
*/
data class MpdSong(
val uri: String,
val title: String?,
val artist: String?,
val album: String?,
val albumArtist: String?,
val track: String?,
val disc: String?,
val date: String?,
val genre: String?,
val duration: Double?, // from "duration" (fractional) or legacy "Time"
val pos: Int?, // queue position, present in queue listings
val id: Int?, // stable queue id, present in queue listings
) {
companion object {
/**
* Parse one song from a metadata map. Returns `null` when there is no
* `file` key (i.e. this block is not a song — e.g. a `directory` entry
* in an `lsinfo` listing).
*/
fun from(values: Map<String, String>): MpdSong? {
val uri = values["file"] ?: return null
return MpdSong(
uri = uri,
title = values["Title"],
artist = values["Artist"],
album = values["Album"],
albumArtist = values["AlbumArtist"],
track = values["Track"],
disc = values["Disc"],
date = values["Date"],
genre = values["Genre"],
duration = values["duration"]?.toDoubleOrNull()
?: values["Time"]?.toDoubleOrNull(),
pos = values["Pos"]?.toIntOrNull(),
id = values["Id"]?.toIntOrNull(),
)
}
}
}
@@ -0,0 +1,27 @@
package ca.ksamad.musicremote.mpd.model
/**
* Database/server statistics from a `stats` response. Durations are in seconds
* and the update timestamp is Unix epoch seconds, both as MPD reports them.
*/
data class MpdStatistics(
val artists: Int,
val albums: Int,
val songs: Int,
val uptimeSeconds: Long,
val playtimeSeconds: Long,
val dbPlaytimeSeconds: Long,
val dbUpdateEpochSeconds: Long,
) {
companion object {
fun from(values: Map<String, String>): MpdStatistics = MpdStatistics(
artists = values["artists"]?.toIntOrNull() ?: 0,
albums = values["albums"]?.toIntOrNull() ?: 0,
songs = values["songs"]?.toIntOrNull() ?: 0,
uptimeSeconds = values["uptime"]?.toLongOrNull() ?: 0,
playtimeSeconds = values["playtime"]?.toLongOrNull() ?: 0,
dbPlaytimeSeconds = values["db_playtime"]?.toLongOrNull() ?: 0,
dbUpdateEpochSeconds = values["db_update"]?.toLongOrNull() ?: 0,
)
}
}
@@ -0,0 +1,64 @@
package ca.ksamad.musicremote.mpd.model
/** Player transport state as reported by the `state` field of `status`. */
enum class PlayerState {
PLAY, PAUSE, STOP, UNKNOWN;
companion object {
fun parse(raw: String?): PlayerState = when (raw) {
"play" -> PLAY
"pause" -> PAUSE
"stop" -> STOP
else -> UNKNOWN
}
}
}
/**
* A snapshot of the player, parsed from a `status` response. Fields absent from
* the response (e.g. `song`/`elapsed` while stopped) are `null`.
*
* See the MPD protocol docs for field semantics. Only the fields a remote UI
* actually drives are surfaced here; more can be added as needed.
*/
data class MpdStatus(
val volume: Int?, // 0..100; null when MPD reports -1 (output closed / volume unavailable)
val repeat: Boolean,
val random: Boolean,
val single: Boolean, // true for "1" or "oneshot"
val consume: Boolean,
val playlistVersion: Int?, // queue version; bumps on every queue change
val playlistLength: Int, // number of songs in the queue
val state: PlayerState,
val song: Int?, // queue position of the current song
val songId: Int?, // stable id of the current song
val nextSong: Int?,
val nextSongId: Int?,
val elapsed: Double?, // seconds into the current song (fractional)
val duration: Double?, // length of the current song in seconds
val bitrate: Int?, // instantaneous kbps
val audio: String?, // e.g. "44100:16:2"
val error: String?, // last player error, if any
) {
companion object {
fun from(values: Map<String, String>): MpdStatus = MpdStatus(
volume = values["volume"]?.toIntOrNull()?.takeIf { it >= 0 },
repeat = values["repeat"] == "1",
random = values["random"] == "1",
single = values["single"].let { it == "1" || it == "oneshot" },
consume = values["consume"] == "1",
playlistVersion = values["playlist"]?.toIntOrNull(),
playlistLength = values["playlistlength"]?.toIntOrNull() ?: 0,
state = PlayerState.parse(values["state"]),
song = values["song"]?.toIntOrNull(),
songId = values["songid"]?.toIntOrNull(),
nextSong = values["nextsong"]?.toIntOrNull(),
nextSongId = values["nextsongid"]?.toIntOrNull(),
elapsed = values["elapsed"]?.toDoubleOrNull(),
duration = values["duration"]?.toDoubleOrNull(),
bitrate = values["bitrate"]?.toIntOrNull(),
audio = values["audio"],
error = values["error"],
)
}
}
@@ -0,0 +1,119 @@
package ca.ksamad.musicremote.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import ca.ksamad.musicremote.data.ConnectionSettings
/**
* App root. Renders whichever [AppScreen] the [PlayerViewModel] decides on: a
* brief loading splash while it reads settings / auto-connects, the connect form
* (first run, after disconnect, or on error), or the now-playing screen.
*/
@Composable
fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) {
val screen by vm.screen.collectAsStateWithLifecycle()
when (val s = screen) {
is AppScreen.Loading -> LoadingScreen()
is AppScreen.Connect -> ConnectScreen(vm, s.settings, s.error)
is AppScreen.Player -> NowPlayingScreen(vm)
}
}
@Composable
private fun LoadingScreen() {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
Text(
"Connecting…",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 16.dp),
)
}
}
@Composable
private fun ConnectScreen(vm: PlayerViewModel, saved: ConnectionSettings, error: String?) {
// Seed the fields from the persisted settings. Keyed on the loaded values so
// the form re-seeds once DataStore delivers them, but a user's edits after
// that stick (rememberSaveable also survives rotation/process death).
var host by rememberSaveable(saved.host) { mutableStateOf(saved.host) }
var port by rememberSaveable(saved.port) { mutableStateOf(saved.port.toString()) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Music Remote", style = MaterialTheme.typography.headlineMedium)
Text(
"Connect to your MPD server",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp, bottom = 24.dp),
)
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = port,
onValueChange = { port = it.filter(Char::isDigit) },
label = { Text("Port") },
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
keyboardType = KeyboardType.Number,
),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { vm.connect(host, port.toIntOrNull() ?: 6600) },
enabled = host.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text("Connect")
}
if (error != null) {
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 16.dp),
)
}
}
}
@@ -0,0 +1,220 @@
package ca.ksamad.musicremote.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ca.ksamad.musicremote.mpd.model.MpdStatus
import ca.ksamad.musicremote.mpd.model.PlayerState
import kotlinx.coroutines.delay
/**
* The now-playing screen: current track, a live seek bar, transport controls,
* volume, and the repeat/random toggles. Everything reads from the
* [PlayerViewModel] flows, so it updates whenever the server pushes a change —
* including changes made from other clients.
*/
@Composable
fun NowPlayingScreen(vm: PlayerViewModel) {
val status by vm.status.collectAsStateWithLifecycle()
val song by vm.currentSong.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(24.dp))
// --- Track metadata --------------------------------------------------
Text(
text = song?.title ?: song?.uri ?: "Nothing playing",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = song?.artist ?: "",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = song?.album ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(32.dp))
SeekBar(status = status, onSeek = { vm.seekTo(it) })
Spacer(Modifier.height(16.dp))
// --- Transport -------------------------------------------------------
val playing = status?.state == PlayerState.PLAY
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
IconButton(onClick = vm::previous, modifier = Modifier.size(56.dp)) {
GlyphText("", 28.sp)
}
FilledIconButton(onClick = vm::togglePlayPause, modifier = Modifier.size(72.dp)) {
GlyphText(if (playing) "" else "", 32.sp)
}
IconButton(onClick = vm::next, modifier = Modifier.size(56.dp)) {
GlyphText("", 28.sp)
}
}
Spacer(Modifier.height(24.dp))
VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) })
Spacer(Modifier.height(16.dp))
// --- Playback options ------------------------------------------------
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FilterChip(
selected = status?.repeat == true,
onClick = { vm.setRepeat(status?.repeat != true) },
label = { Text("Repeat") },
)
FilterChip(
selected = status?.random == true,
onClick = { vm.setRandom(status?.random != true) },
label = { Text("Shuffle") },
)
}
Spacer(Modifier.weight(1f))
TextButton(onClick = vm::disconnect) {
Text("Disconnect")
}
}
}
/**
* Seek bar that ticks locally between server updates. MPD only pushes a change
* event on discrete events (play/pause/seek/song change), not once per second,
* so we advance [MpdStatus.elapsed] locally while playing to keep the bar
* moving, resyncing whenever a fresh status arrives.
*/
@Composable
private fun SeekBar(status: MpdStatus?, onSeek: (Double) -> Unit) {
val duration = status?.duration ?: 0.0
val playing = status?.state == PlayerState.PLAY
// Local playback position, reseeded on every new status snapshot.
var position by remember(status?.songId, status?.elapsed, status?.state) {
mutableFloatStateOf((status?.elapsed ?: 0.0).toFloat())
}
var dragValue by remember { mutableStateOf<Float?>(null) }
// Advance ~4x/sec while playing and not being dragged.
LaunchedEffect(status?.songId, status?.elapsed, status?.state) {
if (!playing) return@LaunchedEffect
while (true) {
delay(250)
if (dragValue == null && position < duration) position += 0.25f
}
}
val shown = dragValue ?: position
Slider(
value = shown.coerceIn(0f, duration.toFloat().coerceAtLeast(0f)),
onValueChange = { dragValue = it },
onValueChangeFinished = {
dragValue?.let { onSeek(it.toDouble()) }
dragValue = null
},
valueRange = 0f..duration.toFloat().coerceAtLeast(1f),
enabled = status != null && duration > 0,
modifier = Modifier.fillMaxWidth(),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatTime(shown.toDouble()), style = MaterialTheme.typography.labelMedium)
Text(formatTime(duration), style = MaterialTheme.typography.labelMedium)
}
}
@Composable
private fun VolumeControl(volume: Int?, onSetVolume: (Int) -> Unit) {
// Local thumb position seeded from the server; committed on release.
var dragValue by remember(volume) { mutableStateOf<Float?>(null) }
val enabled = volume != null
val shown = dragValue ?: (volume ?: 0).toFloat()
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
GlyphText("🔈", 18.sp)
Slider(
value = shown,
onValueChange = { dragValue = it },
onValueChangeFinished = {
dragValue?.let { onSetVolume(it.toInt()) }
dragValue = null
},
valueRange = 0f..100f,
enabled = enabled,
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
)
Text(
text = if (enabled) "${shown.toInt()}" else "",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.size(width = 32.dp, height = 20.dp),
textAlign = TextAlign.End,
)
}
}
/** A centered glyph, used in place of vector icons to avoid an extra dependency. */
@Composable
private fun GlyphText(glyph: String, size: androidx.compose.ui.unit.TextUnit) {
Box(contentAlignment = Alignment.Center) {
Text(glyph, fontSize = size)
}
}
private fun formatTime(seconds: Double): String {
val total = seconds.toInt().coerceAtLeast(0)
val m = total / 60
val s = total % 60
return "%d:%02d".format(m, s)
}
@@ -0,0 +1,118 @@
package ca.ksamad.musicremote.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import ca.ksamad.musicremote.data.ConnectionSettings
import ca.ksamad.musicremote.data.SettingsRepository
import ca.ksamad.musicremote.mpd.MpdClient
import ca.ksamad.musicremote.mpd.MpdConnectionState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/** Which top-level screen to show. Derived from connection + persisted settings. */
sealed interface AppScreen {
/** Reading settings / auto-connecting — a brief splash, never the connect form. */
data object Loading : AppScreen
/** First run, after a disconnect, or a failed connection. */
data class Connect(val settings: ConnectionSettings, val error: String?) : AppScreen
/** Connected: show playback. */
data object Player : AppScreen
}
/**
* Holds the single [MpdClient] for the app and exposes its state to Compose.
*
* On startup it reads the persisted [ConnectionSettings]; if a server was saved
* it auto-connects straight into playback, so a returning user is never
* prompted. The connect form only appears with no saved server, after an
* explicit disconnect, or when a connection fails.
*/
class PlayerViewModel(application: Application) : AndroidViewModel(application) {
private val client = MpdClient()
private val settingsRepo = SettingsRepository(application)
val status = client.status
val currentSong = client.currentSong
/** Persisted settings (defaulted) for seeding the connect form. */
private val settings = settingsRepo.settings.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ConnectionSettings.DEFAULT,
)
// True until the startup auto-connect decision has been made, so we show a
// splash instead of briefly flashing the connect form on cold start.
private val bootstrapping = MutableStateFlow(true)
val screen: StateFlow<AppScreen> = combine(
client.connectionState,
bootstrapping,
settings,
) { connection, booting, saved ->
when {
booting -> AppScreen.Loading
connection is MpdConnectionState.Connected -> AppScreen.Player
connection is MpdConnectionState.Connecting -> AppScreen.Loading
connection is MpdConnectionState.Error -> AppScreen.Connect(saved, connection.message)
else -> AppScreen.Connect(saved, null) // Disconnected after startup
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AppScreen.Loading,
)
init {
viewModelScope.launch {
val saved = settingsRepo.settingsOrNull.first()
if (saved != null) {
connect(saved.host, saved.port)
// Wait until the client actually leaves Disconnected before we
// stop bootstrapping, so the form never flashes over the splash.
client.connectionState.first { it !is MpdConnectionState.Disconnected }
}
bootstrapping.value = false
}
}
fun connect(host: String, port: Int) {
val trimmed = host.trim()
viewModelScope.launch {
// Remember what we last connected to, so it's there next launch.
settingsRepo.save(ConnectionSettings(trimmed, port))
// connect() already routes failures into connectionState; swallow the
// rethrow so a bad host doesn't crash the app.
runCatching { client.connect(trimmed, port) }
}
}
fun disconnect() {
viewModelScope.launch { client.disconnect() }
}
fun togglePlayPause() = fireAndForget { togglePause() }
fun next() = fireAndForget { next() }
fun previous() = fireAndForget { previous() }
fun seekTo(seconds: Double) = fireAndForget { seekCurrent(seconds) }
fun setVolume(volume: Int) = fireAndForget { setVolume(volume) }
fun setRepeat(on: Boolean) = fireAndForget { setRepeat(on) }
fun setRandom(on: Boolean) = fireAndForget { setRandom(on) }
private inline fun fireAndForget(crossinline action: suspend MpdClient.() -> Unit) {
viewModelScope.launch { runCatching { client.action() } }
}
override fun onCleared() {
client.shutdown()
}
}
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Music Remote</string>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<resources>
<!--
The app's UI is drawn entirely by Jetpack Compose (see Theme.kt), so this
Android XML theme only needs to provide a bare, no-action-bar window.
Using a platform parent avoids pulling in the extra Material Components
XML library that we don't need for a Compose-only app.
-->
<style name="Theme.MusicRemote" parent="@android:style/Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,16 @@
package ca.ksamad.musicremote
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* A plain JVM unit test (runs on your machine, no device/emulator needed).
* `gradle testDebugUnitTest` runs these — that's what the Nix build's check
* phase invokes.
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
@@ -0,0 +1,68 @@
package ca.ksamad.musicremote.mpd
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Test
/**
* Live end-to-end test of [MpdClient] against a real server (skipped unless
* `MPD_HOST` is set — see [MpdServerIntegrationTest]).
*
* The interesting part is the idle round-trip: we change the volume on the
* **command** connection and then wait for that change to arrive back through
* the **idle** connection into the `status` flow — proving the two-connection
* push architecture actually works. The volume nudge is reverted afterwards.
*/
class MpdClientIntegrationTest {
private val host = System.getenv("MPD_HOST")
private val port = System.getenv("MPD_PORT")?.toIntOrNull() ?: MpdConnection.DEFAULT_PORT
private val password = System.getenv("MPD_PASSWORD")
@Test
fun clientConnectsAndReceivesIdlePushUpdates() = runBlocking {
assumeTrue("set MPD_HOST to run the live MPD client test", host != null)
val host = host!!
val client = MpdClient()
try {
println("=== MpdClient live test: $host:$port ===")
client.connect(host, port, password)
assertEquals(MpdConnectionState.Connected, client.connectionState.value)
// Initial state, primed during connect().
val initial = withTimeout(5_000) { client.status.first { it != null } }!!
println("connected. state=${initial.state} volume=${initial.volume} song='${client.currentSong.value?.title}'")
val startVolume = initial.volume
if (startVolume == null) {
println("volume unavailable (output closed) — skipping idle-push volume demo")
return@runBlocking
}
// Nudge the volume on the command connection...
val nudged = if (startVolume >= 3) startVolume - 3 else startVolume + 3
println("setting volume $startVolume -> $nudged on the command connection...")
client.setVolume(nudged)
// ...and wait for the change to come back via the idle connection.
val observed = withTimeout(5_000) {
client.status.first { it?.volume == nudged }
}!!
println("idle push observed: status flow now reports volume=${observed.volume}")
assertEquals(nudged, observed.volume)
// Restore the original volume and confirm that round-trips too.
client.setVolume(startVolume)
withTimeout(5_000) { client.status.first { it?.volume == startVolume } }
println("restored volume to $startVolume")
} finally {
client.disconnect()
println("disconnected. state=${client.connectionState.value}")
println("=== OK ===")
}
}
}
@@ -0,0 +1,90 @@
package ca.ksamad.musicremote.mpd
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
/**
* Drives [MpdConnection] against in-memory streams, so the whole text protocol
* (greeting, key/value parsing, ACK handling, binary payloads) is exercised
* with no socket.
*/
class MpdConnectionTest {
private fun conn(serverBytes: ByteArray, out: ByteArrayOutputStream = ByteArrayOutputStream()) =
MpdConnection(ByteArrayInputStream(serverBytes), out)
private fun conn(serverText: String, out: ByteArrayOutputStream = ByteArrayOutputStream()) =
conn(serverText.toByteArray(Charsets.UTF_8), out)
@Test
fun handshake_parsesVersion() {
val c = conn("OK MPD 0.23.5\n")
assertEquals("0.23.5", c.handshake())
assertEquals("0.23.5", c.protocolVersion)
}
@Test
fun handshake_rejectsBadGreeting() {
val c = conn("HELLO THERE\n")
assertThrows(MpdConnectionException::class.java) { c.handshake() }
}
@Test
fun execute_writesCommandWithNewline() {
val out = ByteArrayOutputStream()
val c = conn("OK MPD 0.23.5\nvolume: 50\nOK\n", out)
c.handshake()
c.execute("status")
assertEquals("status\n", out.toString("UTF-8"))
}
@Test
fun execute_parsesKeyValueResponse() {
val c = conn("OK MPD 0.23.5\nvolume: 50\nstate: play\nOK\n")
c.handshake()
val resp = c.execute("status")
assertEquals("50", resp["volume"])
assertEquals("play", resp["state"])
assertNull(resp["nope"])
}
@Test
fun execute_ackBecomesTypedException() {
val c = conn("OK MPD 0.23.5\nACK [2@0] {play} Bad song index\n")
c.handshake()
val ex = assertThrows(MpdAckException::class.java) { c.execute("play 99") }
assertEquals(MpdAckException.Code.ARG, ex.code)
assertEquals("play", ex.command)
assertEquals("Bad song index", ex.serverMessage)
}
@Test
fun execute_readsBinaryPayloadExactly() {
// albumart-style response: metadata, a binary block, then OK.
val payload = byteArrayOf(0x00, 0x10, 0x7F, 0xFF.toByte(), '\n'.code.toByte())
val header = "OK MPD 0.23.5\nsize: 5\ntype: image/png\nbinary: 5\n"
val trailer = "\nOK\n"
val bytes = header.toByteArray() + payload + trailer.toByteArray()
val c = conn(bytes)
c.handshake()
val resp = c.execute("albumart foo 0")
assertEquals("image/png", resp["type"])
assertArrayEquals(payload, resp.binary)
}
@Test
fun execute_throwsOnEofMidResponse() {
val c = conn("OK MPD 0.23.5\nvolume: 50\n") // no OK terminator
c.handshake()
val ex = assertThrows(MpdConnectionException::class.java) { c.execute("status") }
assertTrue(ex.message!!.contains("mid-response"))
}
}
@@ -0,0 +1,51 @@
package ca.ksamad.musicremote.mpd
import org.junit.Assert.assertEquals
import org.junit.Test
class MpdProtocolTest {
@Test
fun quote_wrapsPlainArg() {
assertEquals("\"hello\"", MpdProtocol.quote("hello"))
}
@Test
fun quote_escapesQuotesAndBackslashes() {
// Input: he"llo\world -> "he\"llo\\world"
assertEquals("\"he\\\"llo\\\\world\"", MpdProtocol.quote("he\"llo\\world"))
}
@Test
fun quote_handlesEmptyString() {
assertEquals("\"\"", MpdProtocol.quote(""))
}
@Test
fun command_noArgsIsBareName() {
assertEquals("status", MpdProtocol.command("status"))
}
@Test
fun command_quotesEachArg() {
assertEquals(
"find \"Artist\" \"Nine Inch Nails\"",
MpdProtocol.command("find", "Artist", "Nine Inch Nails"),
)
}
@Test
fun commands_builderProducesExpectedWireForm() {
assertEquals("setvol \"75\"", MpdCommands.setVolume(75))
assertEquals("pause \"1\"", MpdCommands.pause(true))
assertEquals("playid \"42\"", MpdCommands.playId(42))
assertEquals("idle", MpdCommands.idle())
assertEquals("idle \"player\" \"mixer\"", MpdCommands.idle("player", "mixer"))
}
@Test
fun setVolume_clampsToValidRange() {
assertEquals("setvol \"100\"", MpdCommands.setVolume(150))
assertEquals("setvol \"0\"", MpdCommands.setVolume(-5))
}
}
@@ -0,0 +1,58 @@
package ca.ksamad.musicremote.mpd
import ca.ksamad.musicremote.mpd.model.MpdSong
import ca.ksamad.musicremote.mpd.model.MpdStatistics
import ca.ksamad.musicremote.mpd.model.MpdStatus
import org.junit.Assert.assertNotNull
import org.junit.Assume.assumeTrue
import org.junit.Test
/**
* A *live* smoke test against a real MPD server. It is skipped unless `MPD_HOST`
* is set, so the ordinary (and Nix) build never touches the network:
*
* ```
* MPD_HOST=192.168.1.50 MPD_PORT=6600 [MPD_PASSWORD=secret] \
* ./gradlew testDebugUnitTest --tests '*MpdServerIntegrationTest' --rerun-tasks
* ```
*
* It connects, runs `status` / `currentsong` / `stats`, and prints the parsed
* models so you can eyeball that the protocol layer really talks to your server.
*/
class MpdServerIntegrationTest {
private val host = System.getenv("MPD_HOST")
private val port = System.getenv("MPD_PORT")?.toIntOrNull() ?: MpdConnection.DEFAULT_PORT
private val password = System.getenv("MPD_PASSWORD")
@Test
fun connectsAndReadsLiveState() {
assumeTrue("set MPD_HOST to run the live MPD smoke test", host != null)
val host = host!!
println("=== MPD integration smoke test: $host:$port ===")
MpdConnection.connect(host, port, readTimeoutMs = 5_000).use { conn ->
println("greeting: protocol version ${conn.protocolVersion}")
assertNotNull(conn.protocolVersion)
if (password != null) {
conn.execute(MpdCommands.password(password))
println("authenticated with password")
}
val status = MpdStatus.from(conn.execute(MpdCommands.status()).toMap())
println("status : $status")
val song = MpdSong.from(conn.execute(MpdCommands.currentSong()).toMap())
println("current : ${song?.let { "${it.artist}${it.title} (${it.uri})" } ?: "<nothing playing>"}")
val stats = MpdStatistics.from(conn.execute(MpdCommands.stats()).toMap())
println("stats : ${stats.songs} songs, ${stats.albums} albums, ${stats.artists} artists")
// Prove the raw commands list works too — a good connectivity sanity check.
val commands = conn.execute(MpdProtocol.command("commands")).getAll("command")
println("server exposes ${commands.size} commands")
}
println("=== OK ===")
}
}
@@ -0,0 +1,113 @@
package ca.ksamad.musicremote.mpd.model
import ca.ksamad.musicremote.mpd.MpdResponse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class MpdModelTest {
@Test
fun status_parsesTypicalPlayingSnapshot() {
val s = MpdStatus.from(
mapOf(
"volume" to "80",
"repeat" to "0",
"random" to "1",
"single" to "oneshot",
"consume" to "0",
"playlist" to "17",
"playlistlength" to "12",
"state" to "play",
"song" to "3",
"songid" to "104",
"elapsed" to "42.5",
"duration" to "215.3",
"bitrate" to "320",
"audio" to "44100:16:2",
)
)
assertEquals(80, s.volume)
assertFalse(s.repeat)
assertTrue(s.random)
assertTrue(s.single) // "oneshot" counts as single
assertEquals(PlayerState.PLAY, s.state)
assertEquals(3, s.song)
assertEquals(42.5, s.elapsed!!, 0.0001)
assertEquals(215.3, s.duration!!, 0.0001)
}
@Test
fun status_missingMixerYieldsNullVolume() {
val s = MpdStatus.from(mapOf("volume" to "-1", "state" to "stop"))
assertNull(s.volume)
assertEquals(PlayerState.STOP, s.state)
assertEquals(0, s.playlistLength)
}
@Test
fun song_parsesTagsAndDuration() {
val song = MpdSong.from(
mapOf(
"file" to "music/nin/closer.flac",
"Title" to "Closer",
"Artist" to "Nine Inch Nails",
"Album" to "The Downward Spiral",
"Track" to "6",
"duration" to "374.146",
"Pos" to "5",
"Id" to "42",
)
)!!
assertEquals("music/nin/closer.flac", song.uri)
assertEquals("Closer", song.title)
assertEquals("Nine Inch Nails", song.artist)
assertEquals(374.146, song.duration!!, 0.0001)
assertEquals(42, song.id)
}
@Test
fun song_returnsNullWithoutFileKey() {
assertNull(MpdSong.from(mapOf("directory" to "music/nin")))
}
@Test
fun song_fallsBackToLegacyTimeField() {
val song = MpdSong.from(mapOf("file" to "a.mp3", "Time" to "180"))!!
assertEquals(180.0, song.duration!!, 0.0001)
}
@Test
fun response_splitSeparatesRepeatedSongBlocks() {
// Two songs from a playlistinfo-style response, split on "file".
val resp = MpdResponse(
listOf(
"file" to "a.mp3", "Title" to "A", "Pos" to "0", "Id" to "1",
"file" to "b.mp3", "Title" to "B", "Pos" to "1", "Id" to "2",
)
)
val songs = resp.split().mapNotNull { MpdSong.from(it) }
assertEquals(2, songs.size)
assertEquals("A", songs[0].title)
assertEquals("b.mp3", songs[1].uri)
assertEquals(2, songs[1].id)
}
@Test
fun statistics_parseCounts() {
val stats = MpdStatistics.from(
mapOf(
"artists" to "312",
"albums" to "540",
"songs" to "8123",
"uptime" to "98765",
"db_update" to "1700000000",
)
)
assertEquals(312, stats.artists)
assertEquals(8123, stats.songs)
assertEquals(1700000000L, stats.dbUpdateEpochSeconds)
}
}
+7
View File
@@ -0,0 +1,7 @@
// Top-level build file. Plugins are declared here (but not applied) so their
// versions are shared across all modules; each module opts in via `alias(...)`.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+1469
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
# Roadmap / TODO
Near-term work after the working MVP (connect + auto-connect, now-playing with
transport/volume/options, DataStore-persisted settings). Roughly ordered by
priority; not a commitment.
Status legend: `[ ]` todo · `[~]` in progress · `[x]` done
---
## [ ] 1. Proper icons
Replace the placeholder unicode glyphs (`GlyphText` in `NowPlayingScreen.kt`
`⏮ ▶ ⏸ ⏭ 🔈`) with real Material icons.
- Preferred source: **Google Material Symbols / Icons**. In Compose the usual
route is the `androidx.compose.material:material-icons-*` artifacts
(`Icons.Filled.PlayArrow`, `SkipNext`, `VolumeUp`, …). `material-icons-core`
covers the common set; `material-icons-extended` has everything but is large —
prefer core, or import only the specific vector assets we use.
- Platform-bundled drawables (`android.R.drawable.ic_media_*`) exist but look
dated and vary by OEM — avoid; bundle our own for a consistent look.
- Adding the dependency means a `deps.json` regen (see README).
## [ ] 2. Settings menu
A dedicated settings screen where the user can view every setting and reset it.
- Currently settings (host/port) are only editable by hitting **Disconnect** to
get back to the connect form. Add a real settings route.
- Include a **Reset** action that clears DataStore (add a `clear()` to
`SettingsRepository`).
- Will likely want simple navigation (Navigation-Compose, or a screen enum in
`PlayerViewModel`/`AppScreen`).
- Room to grow: password field, connection timeout, theme, keep-screen-on.
## [ ] 3. Cast-style volume control
When the MPD server is playing, present the volume control as a **"casting"
style** remote-volume control — the way MALP (and Google Cast) do — making it
visually clear you're controlling the *server's* output, not the phone's.
- Functionally we already send `setvol` to the server (`MpdClient.setVolume`);
this is mostly a UX/affordance change: a cast icon, "Casting to <host>" label,
distinct styling for remote vs local volume.
- Reference behaviour: MALP.
## [ ] 4. Library browse — albums
A screen to browse all albums on the server (artists can come later).
- MPD commands: `list album group albumartist` (or `list album`), and
`find album "<name>"` to fetch an album's tracks; add to the queue with
`add`/`findadd`. Extend `MpdCommands` + `MpdClient`.
- Grid or list of albums → tap to view/queue tracks.
## [ ] 5. Album / artist images
Pull artwork for the now-playing track and for the album browse grid.
- MPD serves art over the protocol via `albumart <uri> <offset>` and
`readpicture <uri> <offset>` (embedded art). **`MpdConnection` already handles
binary responses**, so the transport groundwork is done — add the commands,
loop over offsets to fetch the whole image, and decode.
- Needs an image loader + caching. Coil (`io.coil-kt`) is the standard Compose
choice; a custom `MpdArtFetcher` could feed it. Dependency → `deps.json` regen.
## [ ] 6. BUG: idle connection drops after a few minutes → kicked to connect page
After a few minutes idling, the app surfaces **"connection closed mid-response"**
and falls back to the connect screen. MALP does not do this.
- Error origin: `MpdConnection.readResponse()` hits EOF and throws
`MpdConnectionException("connection closed mid-response")`; the idle loop's
`catch (IOException)` calls `failAndClose()``MpdConnectionState.Error`
UI shows the connect form.
- Likely causes to investigate:
- **Android Doze / WiFi power-save** tearing down sockets when the screen is
off or the app is backgrounded (most likely on a portable DAP/phone).
- MPD's `connection_timeout` (default 60s) closing a connection it considers
idle — a parked `idle` should count as active, but the *command* connection
sits silent; a periodic `ping` keepalive may be needed.
- NAT/router idle-connection reaping (less likely on LAN).
- Fix direction: don't treat an idle-connection drop as a fatal error — instead
**auto-reconnect transparently** (re-open connections, re-issue `idle`, resync
state) and keep showing the player. Consider a keepalive ping and, for
backgrounded playback control, a foreground service / partial wakelock.
+88
View File
@@ -0,0 +1,88 @@
# Testing on a physical Android device
The fastest inner loop for this app is building the debug APK and installing it
straight onto a real device over USB (an Android phone, or an Android-based DAP
like the FiiO M33). This is quicker than `nix run .#emulate` and tests real
networking to your MPD server.
## One-time device setup
1. **Enable Developer Options**: Settings → About phone → tap **Build number**
seven times.
2. **Enable USB debugging**: Settings → System → Developer options → **USB
debugging**.
3. **Use a data-capable USB cable** and plug the device directly into the
machine. Many cables are charge-only — if the device never appears in
`lsusb`, suspect the cable or the USB mode first.
4. **Unlock the device screen.** The *"Allow USB debugging?"* authorization
dialog only appears while unlocked — this is the usual reason "no popup shows
up". Tap **Allow** and check **Always allow from this computer**.
### NixOS note (this machine)
`adb` is provided by the dev shell (`nix develop`). On this NixOS host it talks
to devices **without** needing `programs.adb.enable`, custom udev rules, or an
`adbusers` group: a plugged-in, authorized device shows up directly as `device`.
If a device instead shows as:
- `unauthorized` → you haven't accepted the on-device prompt yet (unlock the
screen and tap Allow).
- `no permissions`*then* you'd need udev rules; add
`programs.adb.enable = true;` and your user to the `adbusers` group in the
NixOS config, `nixos-rebuild switch`, re-login, and `adb kill-server`. (Not
currently needed here.)
## Verify the device is connected
```sh
adb devices -l # should list the device as `device` (not unauthorized)
adb shell getprop ro.product.model
```
If `adb devices` is empty, check `lsusb` for the device (Pixels show as
`Google Inc. …` / vendor `18d1`), then re-check the cable and USB debugging.
## Build, install, launch
From the repo root, inside `nix develop`:
```sh
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n ca.ksamad.musicremote/.MainActivity # optional auto-launch
```
`-r` reinstalls over the existing app **keeping its data** (so persisted
connection settings survive). To simulate a clean first run:
```sh
adb shell pm clear ca.ksamad.musicremote
```
## Screenshots
```sh
adb exec-out screencap -p > screenshot.png
```
## Troubleshooting
- **"failed to connect to <host>:<port>"** — the device can't reach the MPD
server. Check the device is on the **same WiFi/LAN** as the server. A common
gotcha: WiFi is off on a portable DAP, so `adb shell ip route` shows no route
and `adb shell ping <server>` returns *"Network is unreachable"*.
- **App looks stale after changes** — reinstall the APK; restarting the device
or emulator alone does not update the installed app.
## Wireless alternative (no cable)
Android 11+ supports wireless debugging (Developer options → **Wireless
debugging** → *Pair device with pairing code*), then on the host:
```sh
adb pair <ip>:<pair-port> # enter the code shown on-device
adb connect <ip>:<debug-port>
```
This needs no cable and no udev rules; the device must share your LAN.
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784796856,
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+97
View File
@@ -0,0 +1,97 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
config = {
# The Android SDK components and Android Studio are unfree, and the
# SDK requires accepting its license non-interactively.
allowUnfree = true;
android_sdk.accept_license = true;
};
};
buildToolsVersion = "35.0.0";
# A minimal SDK: just the pieces needed to compile & assemble the app.
# (No emulator/system-image here — those live in the `emulate` output.)
androidComposition = pkgs.androidenv.composeAndroidPackages {
platformVersions = [ "35" ];
buildToolsVersions = [ buildToolsVersion ];
includeEmulator = false;
includeSystemImages = false;
includeNDK = false;
};
androidSdk = androidComposition.androidsdk;
androidHome = "${androidSdk}/libexec/android-sdk";
# The built app, referenced by both `packages.default` and the emulator.
musicRemote = pkgs.callPackage ./package.nix {
inherit androidSdk androidHome buildToolsVersion;
};
applicationId = "ca.ksamad.musicremote";
in
{
formatter = pkgs.nixfmt-rfc-style;
# `nix build` -> reproducible debug APK in ./result/music-remote.apk
packages.default = musicRemote;
# `nix develop` -> command-line dev shell (gradle + SDK wired up).
devShells.default = pkgs.mkShell {
buildInputs = [
pkgs.jdk17
pkgs.gradle
androidSdk
];
ANDROID_HOME = androidHome;
ANDROID_SDK_ROOT = androidHome;
# Same NixOS aapt2 fix as in package.nix, but as a Gradle system prop
# so plain `gradle assembleDebug` works inside the shell.
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${androidHome}/build-tools/${buildToolsVersion}/aapt2";
shellHook = ''
echo "music-remote dev shell try: gradle assembleDebug"
'';
};
# `nix develop .#studio` -> full Android Studio (GUI + emulator manager).
devShells.studio = pkgs.mkShell {
buildInputs = [ pkgs.androidStudioPackages.stable ];
};
# `nix run .#emulate` -> boots an emulator, then installs and launches the
# app on it. `app` points at the built package directory (the script globs
# `*.apk` inside it); `package`/`activity` make it auto-start after boot.
packages.emulate = pkgs.androidenv.emulateApp {
name = "emulate-MusicRemote";
platformVersion = "35";
abiVersion = "x86_64";
# Bare AOSP image. This app only needs the base Android framework
# (TCP/HTTP networking for the MPD client); it uses no Google Play
# Services APIs, so `google_apis`/`_playstore` would just be extra
# weight. Switch to "google_apis" only if you add a Google SDK.
systemImageType = "default";
app = musicRemote;
package = applicationId;
activity = "${applicationId}.MainActivity";
};
}
);
}
+11
View File
@@ -0,0 +1,11 @@
# JVM args for the Gradle daemon.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# Use AndroidX (not the old support library).
android.useAndroidX=true
# Kotlin official code style.
kotlin.code.style=official
# Generate per-module R classes (smaller, faster builds).
android.nonTransitiveRClass=true
+34
View File
@@ -0,0 +1,34 @@
# Gradle "version catalog": one place for every dependency + plugin version.
# Referenced from build scripts as `libs.plugins.*` and `libs.*`.
[versions]
agp = "8.7.3" # Android Gradle Plugin
kotlin = "2.0.21" # Kotlin (its version also drives the Compose compiler plugin)
coreKtx = "1.13.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.10.01" # Compose Bill-of-Materials: pins all Compose lib versions together
coroutines = "1.9.0" # kotlinx-coroutines (async I/O + Flow for the MPD client)
datastore = "1.1.1" # Jetpack DataStore (persisting connection settings)
junit = "4.13.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
[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" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+72
View File
@@ -0,0 +1,72 @@
# Reproducible build of the debug APK, written the way a nixpkgs package would be.
#
# Two hard problems this file solves:
# 1. The Android SDK -> supplied via `androidSdk` (built by androidenv in flake.nix)
# and exposed through ANDROID_HOME.
# 2. Gradle's network -> `gradle` with `mitmCache` intercepts Gradle's HTTP(S)
# dependency fetches traffic and replays it from a hash-locked `deps.json`,
# so the build itself runs fully offline & reproducibly.
#
# Regenerate deps.json whenever dependencies change:
# nix build .#default.mitmCache.updateScript && ./result
{
lib,
stdenv,
gradle,
makeWrapper,
androidSdk,
androidHome,
buildToolsVersion ? "35.0.0",
}:
stdenv.mkDerivation (finalAttrs: {
pname = "music-remote";
version = "0.1.0";
src = ./.;
nativeBuildInputs = [
gradle
androidSdk
makeWrapper
];
ANDROID_HOME = androidHome;
# Some tooling still reads the deprecated variable; set both to be safe.
ANDROID_SDK_ROOT = androidHome;
# Gradle downloads a prebuilt aapt2 from Maven that isn't patched for NixOS's
# loader. Point AGP at the aapt2 that androidenv already patched instead.
gradleFlags = [
"-Pandroid.aapt2FromMavenOverride=${androidHome}/build-tools/${buildToolsVersion}/aapt2"
];
# Build the (unsigned) debug APK. `assembleRelease` would additionally need a
# signing keystore, which is out of scope for a hello-world.
gradleBuildTask = "assembleDebug";
# Run the JVM unit tests as the derivation's check phase.
doCheck = true;
gradleCheckTask = "testDebugUnitTest";
mitmCache = gradle.fetchDeps {
# `pkg` (rather than the manual's `inherit pname`) is what an out-of-tree
# flake package must pass: the manual's shorthand resolves `pkgs.<pname>`,
# which only exists for packages that live inside nixpkgs itself.
pkg = finalAttrs.finalPackage;
data = ./deps.json;
};
installPhase = ''
runHook preInstall
install -Dm644 app/build/outputs/apk/debug/app-debug.apk \
"$out/music-remote.apk"
runHook postInstall
'';
meta = {
description = "Hello-world Kotlin / Jetpack Compose (Material 3) Android app";
platforms = lib.platforms.linux;
license = lib.licenses.mit;
};
})
+24
View File
@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "MusicRemote"
include(":app")