Imported from xVanTuring/MusicDav (
AGENTS.md). Install upstream withnpx skills add xVanTuring/MusicDav. Copyright stays with the author.
AGENTS.md
This file provides guidance for AI coding agents working on the MusicDav Android project.
Build Commands
Core Build
./gradlew build # Build and test entire project
./gradlew assembleDebug # Build debug APK only
./gradlew assembleDebug -x lint # Build without lint checks
./gradlew clean # Clean build artifacts
Testing
./gradlew test # Run all unit tests
./gradlew :app:testDebugUnitTest # Run unit tests for debug variant
./gradlew :app:testDebugUnitTest --tests "*FilePickerDialogTest*" # Run single test class
./gradlew :app:testDebugUnitTest --tests "*FilePickerDialogTest.testParseWebDavBaseUrl*" # Run single test method
./gradlew connectedAndroidTest # Run instrumented tests on connected devices
Code Quality
./gradlew lint # Run Android lint checks
./gradlew :app:lint # Run lint for app module
./gradlew lintDebug # Lint debug variant specifically
Installation
./gradlew installDebug # Install debug APK to connected device
./gradlew installRelease # Install release APK
Code Style Guidelines
Kotlin Code Style
- Follow Kotlin official coding conventions (
kotlin.code.style=officialin gradle.properties) - Use 4 spaces for indentation (no tabs)
- Max line length: No strict limit, but aim for readability (~100-120 characters)
Import Organization
Imports are organized alphabetically by package name:
- Android/AndroidX imports
- Compose imports (androidx.compose.*)
- Third-party library imports
- Project imports (tech.xvanturing.musicdav.*)
- java/kotlin imports (if needed)
import android.app.Activity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import coil3.compose.AsyncImage
import tech.xvanturing.musicdav.data.Album
import kotlinx.coroutines.Dispatchers
Naming Conventions
Classes/Interfaces/Objects:
class WebDavClient { }
data class Album(val name: String) { }
interface MusicRepository { }
object AlbumsRepository { }
Functions/Properties:
fun fetchMusicFiles(): Result<List<MusicFile>> { }
val currentSong: MusicFile? = null
Constants:
private const val PREF_NAME = "albums_prefs"
private const val KEY_ALBUMS = "albums_json"
Composable Functions:
@Composable
fun AlbumListScreen(
albums: List<Album>,
onSelect: (Album) -> Unit
) { }
Data Models
- Use
data classfor immutable data models - Provide default values for optional fields
- Use
valfor immutable properties - Use
varonly when mutability is required
data class WebDavConfig(
val url: String = "",
val username: String = "",
val password: String = ""
)
data class MusicFile(
val name: String,
val url: String,
val path: String,
val size: Long = 0L,
val modifiedDate: Long = 0L
)
Compose UI Guidelines
- Composable functions start with uppercase letter
- Modifier parameter always last with default value
modifier: Modifier = Modifier - State management with
remember { mutableStateOf() } - Use
@OptInfor experimental APIs - Required parameters before optional parameters
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumListScreen(
albums: List<Album>,
onSelect: (Album) -> Unit,
onCreate: (Album, String?) -> Unit,
onDelete: (Album) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var selectedAlbum by remember { mutableStateOf<Album?>(null) }
// ... implementation
}
Asynchronous Operations
- Use Kotlin coroutines for async operations
withContext(Dispatchers.IO)for network I/O- Return
Result<T>for operations that may fail
suspend fun fetchMusicFiles(config: WebDavConfig): Result<List<MusicFile>> =
withContext(Dispatchers.IO) {
try {
val sardine: Sardine = OkHttpSardine()
sardine.setCredentials(config.username, config.password)
val resources = sardine.list(config.url)
Result.success(parseMusicFiles(resources))
} catch (e: Exception) {
Result.failure(e)
}
}
Error Handling
- Use Kotlin's
Result<T>type for error propagation - Log errors with descriptive messages
- Provide user-friendly error messages in UI
try {
// operation
Result.success(data)
} catch (e: Exception) {
Log.e("WebDavClient", "Error fetching music files", e)
Result.failure(e)
}
Logging
- Use Android
Logclass - Tag should match class/component name
- Use appropriate log levels:
d(debug),e(error),i(info)
Log.d("WebDavClient", "Base URL: $baseUrl")
Log.e("WebDavClient", "Error listing resources", e)
Type Safety
- Use nullable types (
T?) appropriately - Use safe call operators (
?.) and Elvis operator (?:) - Avoid
!!operator unless absolutely necessary
val currentSong: MusicFile? = songs.getOrNull(currentIndex)
val coverUrl: String? = embeddedCoverUrl ?: albumCoverUrl
Package Structure
tech.xvanturing.musicdav/
├── MainActivity.kt
├── SimpleMusicService.kt
├── data/
│ └── Models.kt # Data models and repositories
├── ui/
│ ├── theme/ # Compose theming
│ ├── BottomPlayerBar.kt
│ └── screen/ # Screen composables
├── webdav/
│ └── WebDavClient.kt # WebDAV operations
└── player/
└── PlaylistStateController.kt
Android-Specific Guidelines
Context Handling
- Use
LocalContext.currentin Composable functions - Avoid storing Context in long-lived objects
- Use application context when possible
Data Persistence
- Use SharedPreferences with manual JSON serialization
- Use org.json for JSON parsing (see Models.kt for patterns)
WebDAV Image Loading
Use Coil3 with auth headers for WebDAV images:
val headers = NetworkHeaders.Builder()
.set("Authorization", Credentials.basic(username, password))
.build()
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.httpHeaders(headers)
.crossfade(true)
.build(),
contentDescription = description
)
Caching System
Architecture Overview
The caching system consists of three main components:
- CacheManager - High-level cache management
- MusicCacheService - Foreground service handling cache tasks
- MusicCache - Low-level file caching implementation
CacheManager
Location: tech.xvanturing.musicdav.player.CacheManager
Purpose:
- Manages communication with MusicCacheService
- Tracks cache state (individual and album tasks)
- Provides caching operations interface
- Listens to service task state changes
Key State:
data class CacheManagerState(
val totalSize: Long = 0L,
val cachedSongs: List<CacheMetadata> = emptyList(),
val isCaching: Boolean = false,
val cachingProgress: Map<String, Int> = emptyMap(), // taskId -> progress
val cachingStatus: String? = null,
val albumCachingProgress: Map<String, AlbumCacheProgress> = emptyMap()
)
data class AlbumCacheProgress(
val totalSongs: Int,
val completedSongs: Int,
val currentSong: String?
)
Key Methods:
fun bind() // Connect to MusicCacheService
fun unbind() // Disconnect and clean up
fun cacheSong(...) // Cache a single song
fun cacheAlbum(...) // Cache an entire album
suspend fun refreshCacheState() // Update cache state
suspend fun clearCache(context: Context) // Clear all cached files
suspend fun isCached(context: Context, url: String): Boolean // Check if song is cached
Important: Must call cacheService?.addListener(taskListener) in onServiceConnected to receive task updates.
MusicCacheService
Location: tech.xvanturing.musicdav.MusicCacheService
Purpose:
- Foreground service handling cache tasks
- Manages active task queue
- Provides task listener interface
- Shows download progress in notification
Key Features:
- Runs as foreground service for persistent downloads
- Supports multiple concurrent tasks
- Notifies listeners on task state changes
- Auto-stops foreground when all tasks complete
Task Status Flow:
PENDING -> DOWNLOADING -> COMPLETED
|
v
FAILED/CANCELLED
CacheTaskListener Interface:
interface CacheTaskListener {
fun onTaskStarted(taskId: String, musicFile: MusicFile)
fun onTaskProgress(taskId: String, progress: Int)
fun onTaskCompleted(taskId: String, path: String?)
fun onTaskFailed(taskId: String, error: Throwable)
fun onAllTasksCompleted()
}
MusicCache
Location: tech.xvanturing.musicdav.player.MusicCache
Purpose:
- Low-level file caching operations
- Manages cache metadata storage
- Provides cache existence checking
Key Methods:
suspend fun cacheSong(context: Context, musicFile: MusicFile, config: WebDavConfig, onProgress: (Int) -> Unit): Result<String>
suspend fun clearCache(context: Context): Result<Unit>
suspend fun removeCachedSong(context: Context, url: String): Result<Unit>
suspend fun isCached(context: Context, url: String): Boolean
suspend fun getCachedSongs(context: Context): List<CacheMetadata>
suspend fun getCurrentCacheSize(context: Context): Long
Caching in Compose UI
CRITICAL: Always use CacheManager state, never local state
When implementing cache status in UI components:
❌ WRONG - Using local state that doesn't update:
@Composable
fun MusicListItem(musicFile: MusicFile, ...) {
val isCached = remember { mutableStateOf(false) } // Never updates!
LaunchedEffect(musicFile.url) {
isCached.value = MusicCache.isCached(context, musicFile.url)
}
}
✅ CORRECT - Using CacheManager state for reactive updates:
@Composable
fun MusicListItem(
musicFile: MusicFile,
cacheManager: CacheManager?, // Accept CacheManager
...
) {
val isCached = cacheManager?.state?.cachedSongs?.any { it.url == musicFile.url } ?: false
val isCaching = cacheManager?.state?.cachingProgress?.containsKey(musicFile.url) ?: false
// UI automatically updates when cacheManager.state changes
}
Album Caching Progress:
val albumCacheProgress = cacheManager?.state?.albumCachingProgress[albumId]
if (albumCacheProgress != null) {
CircularProgressIndicator(progress = albumCacheProgress.completedSongs / albumCacheProgress.totalSongs)
}
Caching State Lifecycle
For Individual Songs:
- User clicks cache button →
cacheManager.cacheSong() - Service starts task →
onTaskStarted→ updatecachingProgress - Progress updates →
onTaskProgress→ update progress - Task completes →
onTaskCompleted→ add tocachedSongs, remove fromcachingProgress
For Albums:
- User clicks album cache →
cacheManager.cacheAlbum() - Create
AlbumCacheProgressentry inalbumCachingProgress - Queue all songs → individual tasks start
- Each song completion → update
AlbumCacheProgress.completedSongs - All songs complete → remove
AlbumCacheProgress, callonAllTasksCompleted
Common Patterns
Starting Album Cache:
fun cacheAlbum(
albumId: String,
musicFiles: List<MusicFile>,
config: WebDavConfig
) {
scope.launch {
albumProgress[albumId] = AlbumCacheProgress(
totalSongs = musicFiles.size,
completedSongs = 0,
currentSong = null
)
for (musicFile in musicFiles) {
MusicCacheService.startCaching(context, musicFile, config)
}
}
}
Checking Cache Status:
val isCached = cacheManager?.state?.cachedSongs?.any { it.url == musicFile.url } ?: false
val isCaching = cacheManager?.state?.cachingProgress?.containsKey(musicFile.url) ?: false
Cleanup on Album Completion:
if (newProgress.completedSongs >= newProgress.totalSongs) {
albumProgress.remove(albumId)
urlToAlbumId.keys.removeAll { urlToAlbumId[it] == albumId }
_state.value = _state.value.copy(
albumCachingProgress = _state.value.albumCachingProgress - albumId
)
}
Pitfalls to Avoid
- Not registering listener: Always call
cacheService?.addListener(taskListener)inonServiceConnected - Using local state: Never use
remember { mutableStateOf() }for cache status; always derive fromcacheManager.state - Memory leaks: Always call
unbind()inDisposableEffect.onDispose - Not cleaning up album mappings: Remove all URL→albumId mappings when album completes
- Stale state: UI components must accept
CacheManageras parameter to access real-time state
Project Configuration
Build Versions
- compileSdk: 36
- minSdk: 24 (Android 7.0)
- targetSdk: 36
- Java: 11
- Kotlin: 2.0.21
Key Dependencies
- UI: Jetpack Compose with Material3
- Media: Android Media3 (ExoPlayer, Media Session)
- Networking: Sardine (WebDAV), OkHttp
- Image Loading: Coil3 with OkHttp integration
- Testing: JUnit, AndroidX Test
Testing Guidelines
Unit Tests
- Place in
app/src/test/java/tech/xvanturing/musicdav/ - Use JUnit assertions
- Test pure functions and business logic
@Test
fun testParseWebDavBaseUrl_withPath() {
val config = WebDavConfig("https://example.com/path/", "user", "pass")
val baseUrl = parseWebDavBaseUrl(config)
assertEquals("https://example.com", baseUrl)
}
Instrumented Tests
- Place in
app/src/androidTest/java/tech/xvanturing/musicdav/ - Use AndroidX Test framework
- Test Android-specific components
When in Doubt
- Follow existing patterns in the codebase
- Check similar files for implementation examples
- Run
./gradlew assembleDebug -x lintto check compilation - Run
./gradlew testto verify tests pass - Run
./gradlew lintto catch code quality issues
