Overview
Deeztracker Mobile provides comprehensive access to your device’s local music library through Android’s MediaStore API. The app can browse tracks, albums, and artists, with support for metadata editing and efficient querying.
LocalMusicRepository
The LocalMusicRepository handles all interactions with Android’s MediaStore:
class LocalMusicRepository(
private val contentResolver: ContentResolver,
private val playlistRepository: LocalPlaylistRepository? = null
) {
// Repository implementation
}
The repository uses ContentResolver to query MediaStore, ensuring compatibility with Android’s media system.
Querying Tracks
The repository queries all music files from external storage:
suspend fun getAllTracks(): List<LocalTrack> = withContext(Dispatchers.IO) {
val tracks = mutableListOf<LocalTrack>()
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.ALBUM_ID,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.DATA, // File path
MediaStore.Audio.Media.SIZE,
MediaStore.Audio.Media.MIME_TYPE,
MediaStore.Audio.Media.DATE_ADDED,
MediaStore.Audio.Media.DATE_MODIFIED,
MediaStore.Audio.Media.TRACK, // Track number
MediaStore.Audio.Media.YEAR
)
// Filter: only music files (not notifications, ringtones, etc.)
val selection = "${MediaStore.Audio.Media.IS_MUSIC} != 0"
val sortOrder = "${MediaStore.Audio.Media.TITLE} COLLATE NOCASE ASC"
contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
null,
sortOrder
)?.use { cursor ->
// Process results
}
tracks
}
LocalTrack Model
data class LocalTrack(
val id: Long,
val title: String,
val artist: String,
val album: String,
val albumId: Long,
val duration: Long, // milliseconds
val filePath: String,
val size: Long, // bytes
val mimeType: String,
val dateAdded: Long, // Unix timestamp
val dateModified: Long,
val albumArtUri: String?,
val track: Int, // Track number (e.g., 1001 = disc 1, track 1)
val year: Int
)
The track field uses a format where disc * 1000 + trackNumber (e.g., 1001 = disc 1, track 1).
Album Art Handling
Album artwork is retrieved using the album ID:
private fun getAlbumArtUri(albumId: Long): String? {
return try {
val artworkUri = Uri.parse("content://media/external/audio/albumart")
ContentUris.withAppendedId(artworkUri, albumId).toString()
} catch (e: Exception) {
null
}
}
This provides URIs like:
content://media/external/audio/albumart/1234
Using Album Art in UI
@Composable
fun TrackArtwork(model: String?, modifier: Modifier = Modifier) {
AsyncImage(
model = model,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = modifier,
error = painterResource(R.drawable.ic_app_icon) // Fallback
)
}
Browsing by Albums
Get All Albums
suspend fun getAllAlbums(): List<LocalAlbum> = withContext(Dispatchers.IO) {
val albums = mutableListOf<LocalAlbum>()
val projection = arrayOf(
MediaStore.Audio.Albums._ID,
MediaStore.Audio.Albums.ALBUM,
MediaStore.Audio.Albums.ARTIST,
MediaStore.Audio.Albums.NUMBER_OF_SONGS
)
val sortOrder = "${MediaStore.Audio.Albums.ALBUM} COLLATE NOCASE ASC"
contentResolver.query(
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI,
projection,
null,
null,
sortOrder
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Albums._ID)
val albumColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Albums.ALBUM)
val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Albums.ARTIST)
val numberOfSongsColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Albums.NUMBER_OF_SONGS)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
albums.add(
LocalAlbum(
id = id,
title = cursor.getString(albumColumn) ?: "Unknown Album",
artist = cursor.getString(artistColumn) ?: "Unknown Artist",
trackCount = cursor.getInt(numberOfSongsColumn),
albumArtUri = getAlbumArtUri(id)
)
)
}
}
albums
}
LocalAlbum Model
data class LocalAlbum(
val id: Long,
val title: String,
val artist: String,
val trackCount: Int,
val albumArtUri: String?
)
Get Album Tracks
suspend fun getTracksForAlbum(albumId: Long): List<LocalTrack> = withContext(Dispatchers.IO) {
val tracks = getAllTracks()
tracks
.filter { it.albumId == albumId }
.sortedWith(compareBy(
{ track -> val n = track.track ?: 0; if (n > 0) n / 1000 else Int.MAX_VALUE }, // Disc number
{ track -> val n = track.track ?: 0; if (n > 0) n % 1000 else Int.MAX_VALUE } // Track number
))
}
Tracks are sorted by disc number first, then track number, ensuring proper album ordering.
Browsing by Artists
Get All Artists
suspend fun getAllArtists(): List<LocalArtist> = withContext(Dispatchers.IO) {
val artists = mutableListOf<LocalArtist>()
val projection = arrayOf(
MediaStore.Audio.Artists._ID,
MediaStore.Audio.Artists.ARTIST,
MediaStore.Audio.Artists.NUMBER_OF_TRACKS,
MediaStore.Audio.Artists.NUMBER_OF_ALBUMS
)
val sortOrder = "${MediaStore.Audio.Artists.ARTIST} ASC"
contentResolver.query(
MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI,
projection,
null,
null,
sortOrder
)?.use { cursor ->
// Process cursor
}
artists
}
LocalArtist Model
data class LocalArtist(
val id: Long,
val name: String,
val numberOfTracks: Int,
val numberOfAlbums: Int
)
Get Artist Tracks
suspend fun getTracksForArtist(artistName: String): List<LocalTrack> = withContext(Dispatchers.IO) {
val tracks = getAllTracks()
tracks.filter { it.artist == artistName }
}
Search Functionality
Search across title, artist, and album fields:
suspend fun searchTracks(query: String): List<LocalTrack> = withContext(Dispatchers.IO) {
val tracks = getAllTracks()
tracks.filter {
it.title.contains(query, ignoreCase = true) ||
it.artist.contains(query, ignoreCase = true) ||
it.album.contains(query, ignoreCase = true)
}
}
Search uses in-memory filtering for simplicity, as getAllTracks() is already optimized.
Track Deletion
Deeztracker supports track deletion with proper Android permissions:
Request Delete
suspend fun requestDeleteTrack(trackId: Long): IntentSender? = withContext(Dispatchers.IO) {
val uri = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, trackId)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+ (API 30+)
val pendingIntent = MediaStore.createDeleteRequest(contentResolver, listOf(uri))
return@withContext pendingIntent.intentSender
} else {
// Android 10 and below
try {
val deleteResult = contentResolver.delete(uri, null, null)
if (deleteResult > 0) {
playlistRepository?.removeTrackFromAllPlaylists(trackId)
}
return@withContext null
} catch (securityException: RecoverableSecurityException) {
return@withContext securityException.userAction.actionIntent.intentSender
} catch (e: Exception) {
e.printStackTrace()
return@withContext null
}
}
}
Handle Delete Result
suspend fun onTrackDeleted(trackId: Long) = withContext(Dispatchers.IO) {
playlistRepository?.removeTrackFromAllPlaylists(trackId)
}
UI Implementation
val deleteLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartIntentSenderForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
scope.launch {
musicRepository.onTrackDeleted(trackId)
// Refresh UI
}
}
}
// Request deletion
scope.launch {
val intentSender = musicRepository.requestDeleteTrack(trackId)
if (intentSender != null) {
deleteLauncher.launch(
IntentSenderRequest.Builder(intentSender).build()
)
} else {
// Deleted successfully or failed
}
}
On Android 11+, deletion requires user confirmation via system dialog.
Downloaded Tracks Filter
Filter tracks from specific download directories:
suspend fun getDownloadedTracks(downloadPaths: List<String>): List<LocalTrack> = withContext(Dispatchers.IO) {
val allTracks = getAllTracks()
allTracks.filter { track ->
downloadPaths.any { path -> track.filePath.startsWith(path) }
}
}
// Usage
val downloadDir = "/storage/emulated/0/Music/Deeztracker/"
val downloadedTracks = repository.getDownloadedTracks(listOf(downloadDir))
Track by Path Lookup
Retrieve MediaStore ID by file path:
suspend fun getTrackIdByPath(path: String): Long? = withContext(Dispatchers.IO) {
val projection = arrayOf(MediaStore.Audio.Media._ID)
val selection = "${MediaStore.Audio.Media.DATA} = ?"
val selectionArgs = arrayOf(path)
contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
return@withContext cursor.getLong(idColumn)
}
}
return@withContext null
}
This function is used after downloads to link files with MediaStore entries.
Total Storage Space
suspend fun getTotalStorageSpace(): Long = withContext(Dispatchers.IO) {
val path = Environment.getExternalStorageDirectory()
val stat = StatFs(path.path)
val blockSize = stat.blockSizeLong
val totalBlocks = stat.blockCountLong
totalBlocks * blockSize
}
While metadata editing is handled by MetadataEditor, the repository provides read access to all metadata fields:
- Title - Track name
- Artist - Primary artist
- Album - Album name
- Album Artist - Album artist (if different from track artist)
- Track Number - Position in album
- Disc Number - Disc number for multi-disc albums
- Year - Release year
- Genre - Music genre
- Duration - Length in milliseconds
- Bitrate - Audio quality
- MIME Type - File format (e.g., audio/mpeg)
UI Components
Track List
@Composable
fun TrackList(
tracks: List<LocalTrack>,
onTrackClick: (LocalTrack) -> Unit
) {
LazyColumn {
items(tracks) { track ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onTrackClick(track) }
.padding(12.dp)
) {
TrackArtwork(
model = track.albumArtUri,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
fontWeight = FontWeight.Bold,
color = Color.White
)
Text(
text = track.artist,
color = TextGray
)
Text(
text = formatDuration(track.duration),
color = TextGray,
fontSize = 12.sp
)
}
}
}
}
}
Album Grid
@Composable
fun AlbumGrid(
albums: List<LocalAlbum>,
onAlbumClick: (LocalAlbum) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(albums) { album ->
Column(
modifier = Modifier.clickable { onAlbumClick(album) },
horizontalAlignment = Alignment.CenterHorizontally
) {
TrackArtwork(
model = album.albumArtUri,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = album.title,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = album.artist,
color = TextGray,
fontSize = 12.sp,
maxLines = 1
)
}
}
}
}
Artist List
@Composable
fun ArtistList(
artists: List<LocalArtist>,
onArtistClick: (LocalArtist) -> Unit
) {
LazyColumn {
items(artists) { artist ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onArtistClick(artist) }
.padding(16.dp)
) {
// Artist initial in circle
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(Primary.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Text(
text = artist.name.firstOrNull()?.toString() ?: "?",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Primary
)
}
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = artist.name,
fontWeight = FontWeight.Bold,
color = Color.White
)
Text(
text = "${artist.numberOfTracks} tracks • ${artist.numberOfAlbums} albums",
color = TextGray,
fontSize = 12.sp
)
}
}
}
}
}
Best Practices
- Cursor management - Always use
.use { } to close cursors
- Background threads - All queries run on
Dispatchers.IO
- Efficient sorting - Use SQL
ORDER BY instead of Kotlin sorting
- Cached queries - Consider caching
getAllTracks() results
Reliability
- Null safety - Handle missing metadata gracefully
- Permission checks - Verify READ_EXTERNAL_STORAGE permission
- Error handling - Catch and log exceptions
- Playlist cleanup - Remove deleted tracks from playlists
User Experience
- Smart filtering - Exclude ringtones and notifications
- Natural sorting - Case-insensitive alphabetical order
- Album art fallback - Show app icon when artwork missing
- Track numbering - Proper disc and track ordering
Always check for storage permissions before querying MediaStore.