Architecture Pattern
Deeztracker Mobile follows the MVVM (Model-View-ViewModel) architecture pattern with repository pattern for data management and Jetpack Compose for declarative UI.
Layer Architecture
UI Layer (Jetpack Compose)
The UI layer is built entirely with Jetpack Compose using Material 3 design system.
Located in ui/screens/, screens are composable functions that represent full-page views:
LoginScreen.kt - Deezer ARL authentication
LocalMusicScreen.kt - Offline music library
DownloadsScreen.kt - Download management
AlbumScreen.kt - Album details and track listing
ArtistScreen.kt - Artist profile and discography
PlaylistScreen.kt - Playlist details
LyricsScreen.kt - Synchronized lyrics viewer
ImportPlaylistScreen.kt - Playlist import from Deezer
Each screen observes state from its corresponding ViewModel using StateFlow.
Reusable UI components in ui/components/:
TrackArtwork.kt - Album art display with fallback
AlphabeticalFastScroller.kt - Fast scroll for long lists
MarqueeText.kt - Auto-scrolling text for long titles
TrackDetailsDialog.kt - Track metadata viewer
AddToPlaylistBottomSheet.kt - Playlist selection sheet
CreatePlaylistDialog.kt - New playlist creation
EditTrackDialog.kt - Metadata editor
TrackPreviewButton.kt - 30-second preview player
Material 3 theming in ui/theme/: @Composable
fun DeezTrackerTheme (
darkTheme: Boolean = isSystemInDarkTheme (),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) darkColorScheme () else lightColorScheme ()
MaterialTheme (
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
Supports light/dark modes with dynamic color schemes.
ViewModel Layer
ViewModels manage UI state and business logic using Kotlin Coroutines and Flow .
ViewModel Pattern
All ViewModels follow this structure:
Example: AlbumViewModel.kt
class AlbumViewModel (
private val repository: DeezerRepository = DeezerRepository ()
) : ViewModel () {
// Mutable state (private)
private val _album = MutableStateFlow < Album ?>( null )
private val _tracks = MutableStateFlow < List < Track >>( emptyList ())
private val _isLoading = MutableStateFlow ( false )
// Exposed immutable state
val album: StateFlow < Album ?> = _album
val tracks: StateFlow < List < Track >> = _tracks
val isLoading: StateFlow < Boolean > = _isLoading
fun loadAlbum (albumId: Long ) {
viewModelScope. launch {
_isLoading. value = true
try {
val albumData = repository. getAlbum (albumId)
_album. value = albumData
val tracksResponse = repository. getAlbumTracks (albumId)
_tracks. value = tracksResponse. data
} catch (e: Exception ) {
e. printStackTrace ()
} finally {
_isLoading. value = false
}
}
}
}
Key ViewModels :
AlbumViewModel
Loads album metadata
Fetches track listing
Manages download state
ui/screens/AlbumViewModel.kt
ArtistViewModel
Artist profile data
Top tracks
Album discography
ui/screens/ArtistViewModel.kt
LocalMusicViewModel
MediaStore scanning
Track/album/artist grouping
Local playlist management
ui/screens/LocalMusicViewModel.kt
DownloadsViewModel
Downloaded tracks list
File management
Share/delete operations
ui/screens/DownloadsViewModel.kt
Repository Pattern
Repositories abstract data sources and provide clean APIs to ViewModels.
DeezerRepository
Handles all Deezer API interactions using Retrofit :
features/deezer/DeezerRepository.kt
class DeezerRepository {
private val api = DeezerNetwork.api
suspend fun searchTracks (query: String , next: String ? = null ): TrackSearchResponse {
return if (next != null ) api. searchTracksByUrl (next)
else api. searchTracks (query)
}
suspend fun getAlbum (id: Long ) = api. getAlbum (id)
suspend fun getAlbumTracks (id: Long ) = api. getAlbumTracks (id)
suspend fun getArtist (id: Long ) = api. getArtist (id)
suspend fun getPlaylist (id: Long ) = api. getPlaylist (id)
// ...
}
Base URL : https://api.deezer.com/
Key endpoints :
GET /search/track?q={query} - Search tracks
GET /album/{id} - Get album metadata
GET /album/{id}/tracks - Get album tracks
GET /artist/{id} - Get artist info
GET /playlist/{id} - Get playlist details
LocalMusicRepository
Manages local music library using Android MediaStore :
features/localmusic/LocalMusicRepository.kt
class LocalMusicRepository ( private val contentResolver: ContentResolver ) {
suspend fun getAllTracks (): List < LocalTrack > = withContext (Dispatchers.IO) {
val tracks = mutableListOf < LocalTrack >()
val uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
val projection = arrayOf (
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.DATA,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.ALBUM_ID
)
contentResolver. query (uri, projection, null , null , null )?. use { cursor ->
while (cursor. moveToNext ()) {
tracks. add ( LocalTrack (
id = cursor. getLong ( 0 ),
title = cursor. getString ( 1 ),
artist = cursor. getString ( 2 ),
album = cursor. getString ( 3 ),
filePath = cursor. getString ( 4 ),
duration = cursor. getLong ( 5 ),
albumArtUri = getAlbumArt (cursor. getLong ( 6 ))
))
}
}
tracks
}
}
LocalPlaylistRepository
Manages custom playlists using SharedPreferences with JSON serialization:
features/localmusic/LocalPlaylistRepository.kt
class LocalPlaylistRepository (context: Context ) {
private val prefs = context. getSharedPreferences ( "playlists" , Context.MODE_PRIVATE)
private val _playlists = MutableStateFlow < List < LocalPlaylist >>( emptyList ())
val playlists: StateFlow < List < LocalPlaylist >> = _playlists. asStateFlow ()
suspend fun createPlaylist (name: String , trackIds: List < Long > = emptyList ()) {
// Creates a new playlist with unique ID
}
suspend fun addTrackToPlaylist (playlistId: String , trackId: Long ) {
// Adds track to existing playlist
}
suspend fun isFavorite (trackId: Long ): Boolean {
// Checks if track is in Favorites playlist
}
}
Rust FFI Integration (UniFFI)
Deeztracker uses UniFFI to bridge Rust and Kotlin for performance-critical download operations.
Architecture
RustDeezerService
Kotlin wrapper around the Rust library:
features/rusteer/RustDeezerService.kt
class RustDeezerService (context: Context ) {
private val service = RusteerService () // UniFFI generated class
private val prefs: SharedPreferences = context. getSharedPreferences (
"rusteer_prefs" , Context.MODE_PRIVATE
)
suspend fun login (arl: String ): Boolean = withContext (Dispatchers.IO) {
val isValid = service. verifyArl (arl)
if (isValid) {
prefs. edit (). putString (KEY_ARL, arl). apply ()
}
isValid
}
suspend fun downloadTrack (
trackId: String ,
outputDir: String ,
quality: DownloadQuality
): DownloadResult = withContext (Dispatchers.IO) {
val arl = getSavedArl () ?: throw IllegalStateException ( "Not logged in" )
service. downloadTrack (arl, trackId, outputDir, quality)
}
suspend fun downloadAlbum (
albumId: String ,
outputDir: String ,
quality: DownloadQuality
): BatchDownloadResult = withContext (Dispatchers.IO) {
val arl = getSavedArl () ?: throw IllegalStateException ( "Not logged in" )
service. downloadAlbum (arl, albumId, outputDir, quality)
}
}
Rusteer Library Structure
The Rust library (rusteer/) provides:
API Clients
DeezerApi - Public metadata API
GatewayApi - Private download API with ARL auth
rusteer/src/api/
Cryptography
Blowfish decryption for track data
AES decryption for metadata
MD5/SHA1 hashing
rusteer/src/crypto/
Audio Tagging
ID3 tag writing (MP3)
FLAC metadata embedding
Album art injection
rusteer/src/tagging.rs
UniFFI Bindings
Kotlin interface generation
Type conversion (Rust ↔ Kotlin)
Error handling
rusteer/src/bindings.rs
UniFFI Definition (rusteer/src/rusteer.udl):
namespace rusteer {
RusteerService new();
};
interface RusteerService {
boolean verify_arl(string arl);
[Throws=DeezerError]
DownloadResult download_track(string arl, string track_id, string output_dir, DownloadQuality quality);
[Throws=DeezerError]
BatchDownloadResult download_album(string arl, string album_id, string output_dir, DownloadQuality quality);
};
enum DownloadQuality {
"FLAC",
"MP3_320",
"MP3_128",
};
Deeztracker uses AndroidX Media3 (ExoPlayer) for audio playback with a foreground service.
PlayerController
Singleton controller managing playback state:
features/player/PlayerController.kt
class PlayerController ( private val context: Context ) {
companion object {
@Volatile
private var INSTANCE: PlayerController ? = null
fun getInstance (context: Context ): PlayerController {
return INSTANCE ?: synchronized ( this ) {
PlayerController (context.applicationContext). also { INSTANCE = it }
}
}
}
private val _playerState = MutableStateFlow ( PlayerState ())
val playerState: StateFlow < PlayerState > = _playerState. asStateFlow ()
private var mediaController: MediaController ? = null
fun playTrack (track: LocalTrack , playlist: List < LocalTrack >, source: String ? = null ) {
currentPlaylist = playlist
val startIndex = playlist. indexOfFirst { it.id == track.id }
val mediaItems = playlist. map { localTrack ->
MediaItem. Builder ()
. setUri (Uri. fromFile ( File (localTrack.filePath)))
. setMediaId (localTrack.id. toString ())
. setMediaMetadata (
MediaMetadata. Builder ()
. setTitle (localTrack.title)
. setArtist (localTrack.artist)
. setAlbumTitle (localTrack.album)
. setArtworkUri (artworkUri)
. build ()
)
. build ()
}
mediaController?. setMediaItems (mediaItems, startIndex, 0L )
mediaController?. prepare ()
mediaController?. play ()
}
fun togglePlayPause () {
mediaController?. let {
if (it.isPlaying) it. pause () else it. play ()
}
}
fun toggleRepeatMode () {
val newMode = when (mediaController?.repeatMode) {
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_OFF
else -> Player.REPEAT_MODE_OFF
}
mediaController?.repeatMode = newMode
}
}
MusicService
Foreground service implementing MediaSessionService:
features/player/MusicService.kt
class MusicService : MediaSessionService () {
private var mediaSession: MediaSession ? = null
private lateinit var player: ExoPlayer
override fun onCreate () {
super . onCreate ()
player = ExoPlayer. Builder ( this )
. setAudioAttributes (AudioAttributes.DEFAULT, true )
. setHandleAudioBecomingNoisy ( true )
. build ()
mediaSession = MediaSession. Builder ( this , player)
. setCallback ( MediaSessionCallback ())
. build ()
}
override fun onGetSession (controllerInfo: MediaSession .ControllerInfo): MediaSession ? {
return mediaSession
}
}
Features :
Background playback with notification controls
Lock screen media controls
Bluetooth/headphone integration
Audio focus management
Navigation Architecture
Deeztracker uses Jetpack Navigation Compose for declarative navigation.
Navigation Graph
navigation/AppNavigation.kt
@Composable
fun AppNavigation () {
val navController = rememberNavController ()
val context = LocalContext.current
val rustService = remember { RustDeezerService (context) }
val startDestination = if (rustService. isLoggedIn ()) "main" else "login"
NavHost (navController = navController, startDestination = startDestination) {
composable ( "login" ) {
LoginScreen (onLoginSuccess = {
navController. navigate ( "main" ) {
popUpTo ( "login" ) { inclusive = true }
}
})
}
composable ( "main" ) {
MainScreen (
onArtistClick = { artistId -> navController. navigate ( "artist/ $artistId " ) },
onAlbumClick = { albumId -> navController. navigate ( "album/ $albumId " ) },
onPlaylistClick = { playlistId -> navController. navigate ( "playlist/ $playlistId " ) },
onLogout = {
PlayerController. getInstance (context). stop ()
navController. navigate ( "login" ) {
popUpTo ( "main" ) { inclusive = true }
}
}
)
}
composable (
route = "artist/{artistId}" ,
arguments = listOf ( navArgument ( "artistId" ) { type = NavType.LongType })
) { backStackEntry ->
val artistId = backStackEntry.arguments?. getLong ( "artistId" ) ?: return @composable
ArtistScreen (
artistId = artistId,
onBackClick = { navController. popBackStack () },
onAlbumClick = { albumId -> navController. navigate ( "album/ $albumId " ) }
)
}
composable (
route = "album/{albumId}" ,
arguments = listOf ( navArgument ( "albumId" ) { type = NavType.LongType })
) { backStackEntry ->
val albumId = backStackEntry.arguments?. getLong ( "albumId" ) ?: return @composable
AlbumScreen (
albumId = albumId,
onBackClick = { navController. popBackStack () }
)
}
}
}
Routes :
login - Authentication screen
main - Main app with bottom navigation (Search, Local Music, Downloads, Playlists, Settings)
artist/{artistId} - Artist detail page
album/{albumId} - Album detail page
playlist/{playlistId} - Playlist detail page
import_playlist - Playlist import screen
State Management with Flow
All reactive state uses Kotlin Flow for type-safe, lifecycle-aware updates.
StateFlow Pattern
// In Repository/ViewModel
private val _state = MutableStateFlow < DataType >(initialValue)
val state: StateFlow < DataType > = _state. asStateFlow () // Read-only exposure
// In Composable
val state by viewModel.state. collectAsState ()
DownloadManager State Example
features/download/DownloadManager.kt
class DownloadManager {
private val _downloadState = MutableStateFlow < DownloadState >(DownloadState.Idle)
val downloadState: StateFlow < DownloadState > = _downloadState. asStateFlow ()
private val _downloadRefreshTrigger = MutableStateFlow ( 0 )
val downloadRefreshTrigger: StateFlow < Int > = _downloadRefreshTrigger. asStateFlow ()
private suspend fun processRequest (request: DownloadRequest ) {
_downloadState. value = DownloadState. Downloading (
type = DownloadType.TRACK,
title = request.title,
itemId = request.id. toString ()
)
try {
val result = rustService. downloadTrack ( .. .)
_downloadState. value = DownloadState. Completed (
type = DownloadType.TRACK,
title = request.title,
successCount = 1
)
_downloadRefreshTrigger. value += 1 // Trigger UI refresh
} catch (e: Exception ) {
_downloadState. value = DownloadState. Error (
title = request.title,
message = e.message ?: "Unknown error"
)
}
}
}
Dependency Injection
Currently uses manual dependency injection with singleton patterns.
Key Singletons
// PlayerController
val playerController = PlayerController. getInstance (context)
// DownloadManager
val downloadManager = DownloadManager. getInstance (context)
// RustDeezerService
val rustService = RustDeezerService (context)
The project doesn’t use Hilt/Dagger. Dependencies are injected manually via constructors or singleton getInstance() methods.
Dependencies Overview
Core Android
app/build.gradle.kts:76-85
implementation ( "androidx.core:core-ktx:1.12.0" )
implementation ( "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0" )
implementation ( "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0" )
implementation ( "androidx.activity:activity-compose:1.8.2" )
implementation ( platform ( "androidx.compose:compose-bom:2023.08.00" ))
implementation ( "androidx.compose.ui:ui" )
implementation ( "androidx.compose.material3:material3" )
implementation ( "androidx.compose.material:material-icons-extended:1.5.4" )
app/build.gradle.kts:88-91
implementation ( "androidx.media3:media3-exoplayer:1.2.0" )
implementation ( "androidx.media3:media3-session:1.2.0" )
implementation ( "androidx.media3:media3-ui:1.2.0" )
Networking
app/build.gradle.kts:99-102
implementation ( "com.squareup.retrofit2:retrofit:2.9.0" )
implementation ( "com.squareup.retrofit2:converter-gson:2.9.0" )
implementation ( "com.squareup.okhttp3:logging-interceptor:4.12.0" )
FFI & Native
app/build.gradle.kts:96-97
implementation ( "net.java.dev.jna:jna:5.14.0@aar" ) // UniFFI dependency
Other
app/build.gradle.kts:105-114
implementation ( "androidx.navigation:navigation-compose:2.7.6" ) // Navigation
implementation ( "io.coil-kt:coil-compose:2.5.0" ) // Image loading
implementation ( "net.jthink:jaudiotagger:3.0.1" ) // Audio metadata
implementation ( "androidx.core:core-splashscreen:1.0.1" ) // Splash screen
Next Steps
Project Structure Explore the detailed directory layout and file organization
Building from Source Learn how to build and run the application