diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 61dcdbc..72082cd 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -56,6 +56,8 @@ dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.hilt.work)
+ ksp(libs.androidx.hilt.compiler)
// Networking (Subsonic/Navidrome API client)
implementation(libs.retrofit)
@@ -77,10 +79,18 @@ dependencies {
implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.datasource.okhttp)
+ // Offline downloads (Room index + WorkManager)
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ ksp(libs.androidx.room.compiler)
+ implementation(libs.androidx.work.runtime.ktx)
+
testImplementation(libs.junit)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.mockito.core)
testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.androidx.room.testing)
+ testImplementation(libs.androidx.work.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a3bf4bf..6747fb3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
@@ -40,6 +41,32 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt b/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt
index b9201f0..9f3999c 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/DeepwaveApplication.kt
@@ -2,16 +2,39 @@ package com.InfernalAquatics.deepwave
import android.app.Application
import android.content.Context
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
+import androidx.work.WorkManager
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
+import com.InfernalAquatics.deepwave.di.DownloadRepositoryEntryPoint
import com.InfernalAquatics.deepwave.di.OkHttpClientEntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.android.HiltAndroidApp
+import javax.inject.Inject
@HiltAndroidApp
class DeepwaveApplication : Application(), SingletonImageLoader.Factory {
+ @Inject lateinit var workerFactory: HiltWorkerFactory
+
+ override fun onCreate() {
+ super.onCreate()
+
+ // Manual init, not Configuration.Provider: WorkManager's default auto-init (via the
+ // androidx.startup ContentProvider, disabled in the manifest) runs before Hilt's field
+ // injection above is guaranteed to have happened, so it can't reliably read a
+ // Hilt-provided WorkerFactory from this Application. Calling super.onCreate() first
+ // guarantees workerFactory is already set by the time this runs.
+ WorkManager.initialize(this, Configuration.Builder().setWorkerFactory(workerFactory).build())
+
+ // Force DownloadRepository to construct now: its init block starts observing completed
+ // downloads into LocalTrackFiles, which needs to be running before playback ever needs
+ // to resolve a track to a local file, not just whenever the Downloads screen opens first.
+ EntryPoints.get(this, DownloadRepositoryEntryPoint::class.java).downloadRepository()
+ }
+
/**
* Reuses the same signed OkHttpClient as Retrofit so cover-art requests get the
* Subsonic auth params from [com.InfernalAquatics.deepwave.data.network.SubsonicRequestInterceptor]
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/download/BitrateOption.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/download/BitrateOption.kt
new file mode 100644
index 0000000..7c699f3
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/download/BitrateOption.kt
@@ -0,0 +1,9 @@
+package com.InfernalAquatics.deepwave.data.download
+
+/** [kbps] is null for [Original] - that tier uses Subsonic's `download` endpoint, not a transcoded `stream`. */
+enum class BitrateOption(val label: String, val kbps: Int?) {
+ Low("Low (96 kbps)", 96),
+ Normal("Normal (160 kbps)", 160),
+ High("High (320 kbps)", 320),
+ Original("Original", null),
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/download/DownloadRepository.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/download/DownloadRepository.kt
new file mode 100644
index 0000000..ba08476
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/download/DownloadRepository.kt
@@ -0,0 +1,114 @@
+package com.InfernalAquatics.deepwave.data.download
+
+import android.content.Context
+import androidx.work.Constraints
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import com.InfernalAquatics.deepwave.data.local.download.DownloadDao
+import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus
+import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
+import com.InfernalAquatics.deepwave.data.model.Song
+import com.InfernalAquatics.deepwave.media.LocalTrackFiles
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** UI-facing state for a single track, blending Room's persisted status with WorkManager's live progress. */
+sealed interface TrackDownloadState {
+ data object NotDownloaded : TrackDownloadState
+ data class InProgress(val progress: Float?) : TrackDownloadState
+ data object Downloaded : TrackDownloadState
+ data object Failed : TrackDownloadState
+}
+
+@Singleton
+class DownloadRepository @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val downloadDao: DownloadDao,
+ private val localTrackFiles: LocalTrackFiles,
+) {
+ private val workManager = WorkManager.getInstance(context)
+ private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+
+ init {
+ // Keeps Phase 4's LocalOrRemoteDataSource resolving to a local file the moment a
+ // download completes (and reverting to streaming if one is ever removed), with no
+ // dependency the other direction - LocalTrackFiles still knows nothing about downloads.
+ repositoryScope.launch {
+ downloadDao.observeAll()
+ .map { entities ->
+ entities.filter { it.status == DownloadStatus.COMPLETE }
+ .associate { it.trackId to it.localFilePath }
+ }
+ .collect { localTrackFiles.setLocalFiles(it) }
+ }
+ }
+
+ fun downloadedTracks(): Flow> = downloadDao.observeAll()
+
+ fun observeState(trackId: String): Flow = combine(
+ downloadDao.observe(trackId),
+ workManager.getWorkInfosForUniqueWorkFlow(workName(trackId)),
+ ) { entity, workInfos ->
+ val workInfo = workInfos.firstOrNull()
+ when {
+ workInfo != null && !workInfo.state.isFinished -> TrackDownloadState.InProgress(progressOf(workInfo))
+ entity?.status == DownloadStatus.COMPLETE -> TrackDownloadState.Downloaded
+ entity?.status == DownloadStatus.FAILED -> TrackDownloadState.Failed
+ else -> TrackDownloadState.NotDownloaded
+ }
+ }
+
+ fun enqueue(song: Song, bitrateOption: BitrateOption, wifiOnly: Boolean) {
+ val data = workDataOf(
+ DownloadWorker.KEY_TRACK_ID to song.id,
+ DownloadWorker.KEY_TITLE to song.title,
+ DownloadWorker.KEY_ARTIST to song.artistName,
+ DownloadWorker.KEY_ALBUM to song.albumName,
+ DownloadWorker.KEY_COVER_ART_ID to song.coverArtId,
+ DownloadWorker.KEY_BITRATE_KBPS to (bitrateOption.kbps ?: -1),
+ DownloadWorker.KEY_IS_ORIGINAL to (bitrateOption == BitrateOption.Original),
+ )
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
+ .build()
+ val request = OneTimeWorkRequestBuilder()
+ .setInputData(data)
+ .setConstraints(constraints)
+ .build()
+ // REPLACE (not KEEP): a track mid-download is never re-offered "Download" by the UI, so
+ // this only ever fires again to retry a failed one - which needs a fresh request anyway.
+ workManager.enqueueUniqueWork(workName(song.id), ExistingWorkPolicy.REPLACE, request)
+ }
+
+ fun cancel(trackId: String) {
+ workManager.cancelUniqueWork(workName(trackId))
+ }
+
+ suspend fun delete(trackId: String) {
+ downloadDao.get(trackId)?.localFilePath?.let { path ->
+ if (path.isNotBlank()) File(path).delete()
+ }
+ downloadDao.delete(trackId)
+ }
+
+ private fun workName(trackId: String) = "download-$trackId"
+
+ private fun progressOf(workInfo: WorkInfo): Float? {
+ val bytes = workInfo.progress.getLong(DownloadWorker.KEY_PROGRESS_BYTES, -1L)
+ val total = workInfo.progress.getLong(DownloadWorker.KEY_PROGRESS_TOTAL, -1L)
+ return if (bytes >= 0 && total > 0) bytes.toFloat() / total else null
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/download/DownloadWorker.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/download/DownloadWorker.kt
new file mode 100644
index 0000000..8259579
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/download/DownloadWorker.kt
@@ -0,0 +1,188 @@
+package com.InfernalAquatics.deepwave.data.download
+
+import android.content.Context
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.Environment
+import androidx.core.app.NotificationChannelCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.ForegroundInfo
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import com.InfernalAquatics.deepwave.R
+import com.InfernalAquatics.deepwave.data.local.download.DownloadDao
+import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus
+import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
+import com.InfernalAquatics.deepwave.data.network.downloadUrl
+import com.InfernalAquatics.deepwave.data.network.streamUrl
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+import java.io.IOException
+
+/** Downloads one track at a given [BitrateOption] and records it in [DownloadDao]. One work item per track id. */
+@HiltWorker
+class DownloadWorker @AssistedInject constructor(
+ @Assisted context: Context,
+ @Assisted params: WorkerParameters,
+ private val okHttpClient: OkHttpClient,
+ private val downloadDao: DownloadDao,
+) : CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ val trackId = inputData.getString(KEY_TRACK_ID) ?: return Result.failure()
+ val title = inputData.getString(KEY_TITLE).orEmpty()
+ val artistName = inputData.getString(KEY_ARTIST)
+ val albumName = inputData.getString(KEY_ALBUM)
+ val coverArtId = inputData.getString(KEY_COVER_ART_ID)
+ val bitrateKbps = inputData.getInt(KEY_BITRATE_KBPS, -1).takeIf { it > 0 }
+ val isOriginal = inputData.getBoolean(KEY_IS_ORIGINAL, false)
+
+ setForeground(createForegroundInfo(title))
+ downloadDao.upsert(pendingEntity(trackId, title, artistName, albumName, coverArtId, bitrateKbps, DownloadStatus.DOWNLOADING))
+
+ return try {
+ val outputFile = withContext(Dispatchers.IO) {
+ downloadToFile(trackId, isOriginal, bitrateKbps)
+ }
+ downloadDao.upsert(
+ DownloadedTrackEntity(
+ trackId = trackId,
+ title = title,
+ artistName = artistName,
+ albumName = albumName,
+ coverArtId = coverArtId,
+ localFilePath = outputFile.absolutePath,
+ fileSizeBytes = outputFile.length(),
+ bitrateKbps = bitrateKbps,
+ format = outputFile.extension,
+ downloadedAt = System.currentTimeMillis(),
+ status = DownloadStatus.COMPLETE,
+ ),
+ )
+ Result.success()
+ } catch (e: IOException) {
+ downloadDao.upsert(pendingEntity(trackId, title, artistName, albumName, coverArtId, bitrateKbps, DownloadStatus.FAILED))
+ if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure()
+ }
+ }
+
+ private suspend fun downloadToFile(trackId: String, isOriginal: Boolean, bitrateKbps: Int?): File {
+ val url = if (isOriginal) downloadUrl(trackId) else streamUrl(trackId, bitrateKbps)
+ val request = Request.Builder().url(url).build()
+ okHttpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) throw IOException("HTTP ${response.code}")
+ val body = response.body ?: throw IOException("Empty response body")
+
+ val extension = extensionFromContentType(response.header("Content-Type"))
+ val outputDir = File(applicationContext.getExternalFilesDir(Environment.DIRECTORY_MUSIC), "downloads")
+ outputDir.mkdirs()
+
+ // A re-download at a different quality can get a different Content-Type (e.g. Original's
+ // untranscoded source vs. a transcoded tier), which changes the extension. Without this,
+ // the old file is never removed - Room's localFilePath just moves to the new file, orphaning
+ // the previous one on disk forever.
+ outputDir.listFiles { file -> file.name.startsWith("$trackId.") }?.forEach { it.delete() }
+
+ val outputFile = File(outputDir, "$trackId.$extension")
+
+ val totalBytes = body.contentLength()
+ var bytesCopied = 0L
+ var lastReportedPercent = -1
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ body.byteStream().use { input ->
+ outputFile.outputStream().use { output ->
+ var read = input.read(buffer)
+ while (read >= 0) {
+ output.write(buffer, 0, read)
+ bytesCopied += read
+ if (totalBytes > 0) {
+ val percent = (bytesCopied * 100 / totalBytes).toInt()
+ if (percent != lastReportedPercent) {
+ lastReportedPercent = percent
+ setProgress(workDataOf(KEY_PROGRESS_BYTES to bytesCopied, KEY_PROGRESS_TOTAL to totalBytes))
+ }
+ }
+ read = input.read(buffer)
+ }
+ }
+ }
+ return outputFile
+ }
+ }
+
+ private fun pendingEntity(
+ trackId: String,
+ title: String,
+ artistName: String?,
+ albumName: String?,
+ coverArtId: String?,
+ bitrateKbps: Int?,
+ status: DownloadStatus,
+ ) = DownloadedTrackEntity(
+ trackId = trackId,
+ title = title,
+ artistName = artistName,
+ albumName = albumName,
+ coverArtId = coverArtId,
+ localFilePath = "",
+ fileSizeBytes = 0,
+ bitrateKbps = bitrateKbps,
+ format = "",
+ downloadedAt = System.currentTimeMillis(),
+ status = status,
+ )
+
+ private fun createForegroundInfo(title: String): ForegroundInfo {
+ val notificationManager = NotificationManagerCompat.from(applicationContext)
+ notificationManager.createNotificationChannel(
+ NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
+ .setName(applicationContext.getString(R.string.download_notification_channel_name))
+ .build(),
+ )
+ val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
+ .setContentTitle(applicationContext.getString(R.string.download_notification_title))
+ .setContentText(title)
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setOngoing(true)
+ .build()
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
+ } else {
+ ForegroundInfo(NOTIFICATION_ID, notification)
+ }
+ }
+
+ companion object {
+ const val KEY_TRACK_ID = "trackId"
+ const val KEY_TITLE = "title"
+ const val KEY_ARTIST = "artist"
+ const val KEY_ALBUM = "album"
+ const val KEY_COVER_ART_ID = "coverArtId"
+ const val KEY_BITRATE_KBPS = "bitrateKbps"
+ const val KEY_IS_ORIGINAL = "isOriginal"
+ const val KEY_PROGRESS_BYTES = "progressBytes"
+ const val KEY_PROGRESS_TOTAL = "progressTotal"
+
+ private const val CHANNEL_ID = "downloads"
+ private const val NOTIFICATION_ID = 4200
+ private const val MAX_RETRIES = 3
+ }
+}
+
+private fun extensionFromContentType(contentType: String?): String = when {
+ contentType == null -> "audio"
+ contentType.contains("mpeg") -> "mp3"
+ contentType.contains("flac") -> "flac"
+ contentType.contains("ogg") -> "ogg"
+ contentType.contains("mp4") || contentType.contains("aac") -> "m4a"
+ contentType.contains("wav") -> "wav"
+ else -> "audio"
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/DeepwaveDatabase.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/DeepwaveDatabase.kt
new file mode 100644
index 0000000..197e519
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/DeepwaveDatabase.kt
@@ -0,0 +1,12 @@
+package com.InfernalAquatics.deepwave.data.local
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import com.InfernalAquatics.deepwave.data.local.download.DownloadDao
+import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
+
+/** Single shared database - Phase 6's playlist tables join this same instance rather than a second DB. */
+@Database(entities = [DownloadedTrackEntity::class], version = 1, exportSchema = false)
+abstract class DeepwaveDatabase : RoomDatabase() {
+ abstract fun downloadDao(): DownloadDao
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/download/DownloadDao.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/download/DownloadDao.kt
new file mode 100644
index 0000000..abcfd5b
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/download/DownloadDao.kt
@@ -0,0 +1,24 @@
+package com.InfernalAquatics.deepwave.data.local.download
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Upsert
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface DownloadDao {
+ @Query("SELECT * FROM downloaded_tracks ORDER BY downloadedAt DESC")
+ fun observeAll(): Flow>
+
+ @Query("SELECT * FROM downloaded_tracks WHERE trackId = :trackId")
+ fun observe(trackId: String): Flow
+
+ @Query("SELECT * FROM downloaded_tracks WHERE trackId = :trackId")
+ suspend fun get(trackId: String): DownloadedTrackEntity?
+
+ @Upsert
+ suspend fun upsert(entity: DownloadedTrackEntity)
+
+ @Query("DELETE FROM downloaded_tracks WHERE trackId = :trackId")
+ suspend fun delete(trackId: String)
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/download/DownloadedTrackEntity.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/download/DownloadedTrackEntity.kt
new file mode 100644
index 0000000..77fdf9f
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/download/DownloadedTrackEntity.kt
@@ -0,0 +1,26 @@
+package com.InfernalAquatics.deepwave.data.local.download
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+enum class DownloadStatus { QUEUED, DOWNLOADING, COMPLETE, FAILED }
+
+/**
+ * A downloaded (or in-flight/failed) track. Carries enough metadata (title/artist/album/cover)
+ * to render an offline downloads list without touching the network - the whole point of a
+ * downloads screen is that it works with no connection.
+ */
+@Entity(tableName = "downloaded_tracks")
+data class DownloadedTrackEntity(
+ @PrimaryKey val trackId: String,
+ val title: String,
+ val artistName: String?,
+ val albumName: String?,
+ val coverArtId: String?,
+ val localFilePath: String,
+ val fileSizeBytes: Long,
+ val bitrateKbps: Int?,
+ val format: String,
+ val downloadedAt: Long,
+ val status: DownloadStatus,
+)
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/StreamUrls.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/StreamUrls.kt
index 8306581..0568104 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/data/network/StreamUrls.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/network/StreamUrls.kt
@@ -3,9 +3,19 @@ package com.InfernalAquatics.deepwave.data.network
import com.InfernalAquatics.deepwave.di.PLACEHOLDER_BASE_URL
/**
- * Builds a playback request URL for ExoPlayer, the same way [coverArtUrl] does for Coil:
- * [SubsonicRequestInterceptor] rewrites and signs it, and ExoPlayer's OkHttpDataSource shares
- * the same OkHttpClient (see DeepwavePlaybackService), so this resolves like every other
- * Subsonic request.
+ * Builds a playback/download request URL, the same way [coverArtUrl] does for Coil:
+ * [SubsonicRequestInterceptor] rewrites and signs it, and both ExoPlayer's OkHttpDataSource
+ * and DownloadWorker share the same OkHttpClient, so this resolves like every other Subsonic
+ * request.
+ *
+ * [maxBitRate], when set, asks the server to transcode down to that bitrate. Subsonic's
+ * separate `download` endpoint ([downloadUrl]) ignores `maxBitRate` entirely and always
+ * returns the original file - bitrate-limited downloads must go through here instead.
*/
-fun streamUrl(songId: String): String = "${PLACEHOLDER_BASE_URL}rest/stream.view?id=$songId"
+fun streamUrl(songId: String, maxBitRate: Int? = null): String {
+ val base = "${PLACEHOLDER_BASE_URL}rest/stream.view?id=$songId"
+ return if (maxBitRate != null) "$base&maxBitRate=$maxBitRate" else base
+}
+
+/** The original, untranscoded file - use for "Original" quality downloads only, never streaming. */
+fun downloadUrl(songId: String): String = "${PLACEHOLDER_BASE_URL}rest/download.view?id=$songId"
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/settings/DownloadPreferences.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/settings/DownloadPreferences.kt
new file mode 100644
index 0000000..51a8288
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/settings/DownloadPreferences.kt
@@ -0,0 +1,39 @@
+package com.InfernalAquatics.deepwave.data.settings
+
+import android.content.Context
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.InfernalAquatics.deepwave.data.download.BitrateOption
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.downloadPreferencesDataStore by preferencesDataStore(name = "deepwave_download_preferences")
+
+/** Plain (unencrypted) settings - separate from [com.InfernalAquatics.deepwave.data.security.CredentialsStore]'s store, which has no reason to hold anything but credentials. */
+@Singleton
+class DownloadPreferences @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val wifiOnlyKey = booleanPreferencesKey("wifi_only_downloads")
+ private val defaultBitrateKey = stringPreferencesKey("default_bitrate")
+
+ val wifiOnly: Flow = context.downloadPreferencesDataStore.data.map { it[wifiOnlyKey] ?: true }
+
+ val defaultBitrate: Flow = context.downloadPreferencesDataStore.data.map { prefs ->
+ prefs[defaultBitrateKey]?.let { name -> runCatching { BitrateOption.valueOf(name) }.getOrNull() }
+ ?: BitrateOption.Normal
+ }
+
+ suspend fun setWifiOnly(value: Boolean) {
+ context.downloadPreferencesDataStore.edit { it[wifiOnlyKey] = value }
+ }
+
+ suspend fun setDefaultBitrate(option: BitrateOption) {
+ context.downloadPreferencesDataStore.edit { it[defaultBitrateKey] = option.name }
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/di/DatabaseModule.kt b/app/src/main/java/com/InfernalAquatics/deepwave/di/DatabaseModule.kt
new file mode 100644
index 0000000..426d1d5
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/di/DatabaseModule.kt
@@ -0,0 +1,29 @@
+package com.InfernalAquatics.deepwave.di
+
+import android.content.Context
+import androidx.room.Room
+import com.InfernalAquatics.deepwave.data.local.DeepwaveDatabase
+import com.InfernalAquatics.deepwave.data.local.download.DownloadDao
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DatabaseModule {
+
+ @Provides
+ @Singleton
+ fun provideDatabase(@ApplicationContext context: Context): DeepwaveDatabase =
+ Room.databaseBuilder(context, DeepwaveDatabase::class.java, "deepwave.db")
+ // Schema isn't stable yet (Phase 6 adds playlist tables to this same DB) - fine to
+ // drop and recreate rather than write real migrations before that settles.
+ .fallbackToDestructiveMigration(dropAllTables = true)
+ .build()
+
+ @Provides
+ fun provideDownloadDao(database: DeepwaveDatabase): DownloadDao = database.downloadDao()
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/di/DownloadRepositoryEntryPoint.kt b/app/src/main/java/com/InfernalAquatics/deepwave/di/DownloadRepositoryEntryPoint.kt
new file mode 100644
index 0000000..21c48cf
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/di/DownloadRepositoryEntryPoint.kt
@@ -0,0 +1,13 @@
+package com.InfernalAquatics.deepwave.di
+
+import com.InfernalAquatics.deepwave.data.download.DownloadRepository
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+/** Lets [com.InfernalAquatics.deepwave.DeepwaveApplication] force DownloadRepository to construct at startup. */
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface DownloadRepositoryEntryPoint {
+ fun downloadRepository(): DownloadRepository
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt
index 61c4589..7987ca4 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt
@@ -5,6 +5,7 @@ import android.content.Intent
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
@@ -43,8 +44,14 @@ class DeepwavePlaybackService : MediaLibraryService() {
override fun onCreate() {
super.onCreate()
+ // DefaultDataSource, not OkHttpDataSource directly: ResolvingDataSource only rewrites the
+ // DataSpec's URI (e.g. to file:// for a downloaded track) and always hands it to the same
+ // wrapped upstream DataSource to open. OkHttpDataSource can only open http(s) URLs, so a
+ // rewritten file:// URI would fail there with "Malformed URL". DefaultDataSource routes by
+ // scheme instead - file:// to FileDataSource, http(s) to the signed OkHttp factory below.
val upstreamFactory = OkHttpDataSource.Factory(okHttpClient)
- val resolvingFactory = ResolvingDataSource.Factory(upstreamFactory, localOrRemoteDataSource)
+ val defaultDataSourceFactory = DefaultDataSource.Factory(this, upstreamFactory)
+ val resolvingFactory = ResolvingDataSource.Factory(defaultDataSourceFactory, localOrRemoteDataSource)
val mediaSourceFactory = DefaultMediaSourceFactory(this).setDataSourceFactory(resolvingFactory)
player = ExoPlayer.Builder(this)
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt
index 6da8a4a..858a1bd 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/SubsonicMediaItemMapper.kt
@@ -3,6 +3,7 @@ package com.InfernalAquatics.deepwave.media
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
+import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
import com.InfernalAquatics.deepwave.data.network.streamUrl
@@ -31,3 +32,26 @@ fun Song.toMediaItem(): MediaItem {
.setMediaMetadata(metadata)
.build()
}
+
+/**
+ * Builds the same kind of MediaItem [Song.toMediaItem] does, still pointed at the stream URL
+ * rather than [DownloadedTrackEntity.localFilePath] directly - [LocalOrRemoteDataSource] already
+ * rewrites that URL onto the local file transparently, so playback from the Downloads screen
+ * goes through the exact same resolution path as playback from anywhere else.
+ */
+fun DownloadedTrackEntity.toMediaItem(): MediaItem {
+ val metadata = MediaMetadata.Builder()
+ .setTitle(title)
+ .setArtist(artistName)
+ .setAlbumTitle(albumName)
+ .apply {
+ coverArtUrl(coverArtId)?.let { setArtworkUri(Uri.parse(it)) }
+ }
+ .build()
+
+ return MediaItem.Builder()
+ .setMediaId(songMediaId(trackId))
+ .setUri(streamUrl(trackId))
+ .setMediaMetadata(metadata)
+ .build()
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/BitratePickerSheet.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/BitratePickerSheet.kt
new file mode 100644
index 0000000..008f128
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/BitratePickerSheet.kt
@@ -0,0 +1,67 @@
+package com.InfernalAquatics.deepwave.ui.components
+
+import android.content.res.Configuration
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.InfernalAquatics.deepwave.R
+import com.InfernalAquatics.deepwave.data.download.BitrateOption
+import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BitratePickerSheet(
+ onSelect: (BitrateOption) -> Unit,
+ onDismiss: () -> Unit,
+ defaultOption: BitrateOption = BitrateOption.Normal,
+) {
+ ModalBottomSheet(onDismissRequest = onDismiss) {
+ BitratePickerContent(onSelect = onSelect, defaultOption = defaultOption)
+ }
+}
+
+@Composable
+private fun BitratePickerContent(onSelect: (BitrateOption) -> Unit, defaultOption: BitrateOption) {
+ Column(modifier = Modifier.padding(bottom = 24.dp)) {
+ Text(
+ text = stringResource(R.string.download_pick_quality),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ BitrateOption.entries.forEach { option ->
+ ListItem(
+ headlineContent = { Text(option.label) },
+ trailingContent = {
+ if (option == defaultOption) {
+ Icon(Icons.Filled.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onSelect(option) },
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411)
+@Composable
+private fun BitratePickerContentPreview() {
+ DeepwaveTheme {
+ BitratePickerContent(onSelect = {}, defaultOption = BitrateOption.Normal)
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/DownloadStateBadge.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/DownloadStateBadge.kt
new file mode 100644
index 0000000..dd56008
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/components/DownloadStateBadge.kt
@@ -0,0 +1,72 @@
+package com.InfernalAquatics.deepwave.ui.components
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.DownloadDone
+import androidx.compose.material.icons.filled.ErrorOutline
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.InfernalAquatics.deepwave.R
+import com.InfernalAquatics.deepwave.data.download.TrackDownloadState
+import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
+
+/** Tapping it opens the bitrate picker when not downloaded, or retries on failure; no-op otherwise. */
+@Composable
+fun DownloadStateBadge(state: TrackDownloadState, onClick: () -> Unit) {
+ when (state) {
+ TrackDownloadState.NotDownloaded -> IconButton(onClick = onClick) {
+ Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.download_action))
+ }
+ is TrackDownloadState.InProgress -> {
+ if (state.progress != null) {
+ CircularProgressIndicator(
+ progress = { state.progress },
+ modifier = Modifier.size(24.dp),
+ )
+ } else {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ }
+ }
+ TrackDownloadState.Downloaded -> Icon(
+ Icons.Filled.DownloadDone,
+ contentDescription = stringResource(R.string.download_downloaded),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ TrackDownloadState.Failed -> IconButton(onClick = onClick) {
+ Icon(Icons.Filled.ErrorOutline, contentDescription = stringResource(R.string.download_failed), tint = MaterialTheme.colorScheme.error)
+ }
+ }
+}
+
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun DownloadStateBadgeNotDownloadedPreview() {
+ DeepwaveTheme { DownloadStateBadge(state = TrackDownloadState.NotDownloaded, onClick = {}) }
+}
+
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun DownloadStateBadgeInProgressPreview() {
+ DeepwaveTheme { DownloadStateBadge(state = TrackDownloadState.InProgress(0.4f), onClick = {}) }
+}
+
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun DownloadStateBadgeDownloadedPreview() {
+ DeepwaveTheme { DownloadStateBadge(state = TrackDownloadState.Downloaded, onClick = {}) }
+}
+
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun DownloadStateBadgeFailedPreview() {
+ DeepwaveTheme { DownloadStateBadge(state = TrackDownloadState.Failed, onClick = {}) }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsScreen.kt
new file mode 100644
index 0000000..c65c28e
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsScreen.kt
@@ -0,0 +1,133 @@
+package com.InfernalAquatics.deepwave.ui.downloads
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.media3.common.util.UnstableApi
+import com.InfernalAquatics.deepwave.R
+import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus
+import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
+import com.InfernalAquatics.deepwave.data.network.coverArtUrl
+import com.InfernalAquatics.deepwave.ui.components.TrackRow
+import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
+
+@UnstableApi
+@Composable
+fun DownloadsScreen(
+ onBack: () -> Unit,
+ viewModel: DownloadsViewModel = hiltViewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ DownloadsContent(
+ uiState = uiState,
+ onBack = onBack,
+ onTrackClick = viewModel::playTrack,
+ onRemove = viewModel::remove,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun DownloadsContent(
+ uiState: DownloadsUiState,
+ onBack: () -> Unit,
+ onTrackClick: (Int) -> Unit,
+ onRemove: (String) -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.downloads_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
+ }
+ },
+ )
+ },
+ ) { innerPadding ->
+ if (uiState.tracks.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text = stringResource(R.string.downloads_empty), style = MaterialTheme.typography.bodyLarge)
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ ) {
+ itemsIndexed(uiState.tracks, key = { _, track -> track.trackId }) { index, track ->
+ TrackRow(
+ title = track.title,
+ subtitle = track.artistName.orEmpty(),
+ coverArtUrl = coverArtUrl(track.coverArtId),
+ onClick = { onTrackClick(index) },
+ trailingContent = {
+ IconButton(onClick = { onRemove(track.trackId) }) {
+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.download_remove))
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+private val sampleTracks = List(3) { index ->
+ DownloadedTrackEntity(
+ trackId = "sample-$index",
+ title = "Sample Track ${index + 1}",
+ artistName = "Sample Artist",
+ albumName = "Sample Album",
+ coverArtId = null,
+ localFilePath = "/sample/path",
+ fileSizeBytes = 4_000_000,
+ bitrateKbps = 160,
+ format = "mp3",
+ downloadedAt = System.currentTimeMillis(),
+ status = DownloadStatus.COMPLETE,
+ )
+}
+
+@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
+@Composable
+private fun DownloadsContentPreview() {
+ DeepwaveTheme {
+ DownloadsContent(uiState = DownloadsUiState(tracks = sampleTracks), onBack = {}, onTrackClick = {}, onRemove = {})
+ }
+}
+
+@Preview(name = "Empty", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
+@Composable
+private fun DownloadsContentEmptyPreview() {
+ DeepwaveTheme {
+ DownloadsContent(uiState = DownloadsUiState(), onBack = {}, onTrackClick = {}, onRemove = {})
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsViewModel.kt
new file mode 100644
index 0000000..cd003dc
--- /dev/null
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsViewModel.kt
@@ -0,0 +1,44 @@
+package com.InfernalAquatics.deepwave.ui.downloads
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.media3.common.util.UnstableApi
+import com.InfernalAquatics.deepwave.data.download.DownloadRepository
+import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus
+import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity
+import com.InfernalAquatics.deepwave.media.PlaybackController
+import com.InfernalAquatics.deepwave.media.toMediaItem
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class DownloadsUiState(val tracks: List = emptyList())
+
+@UnstableApi
+@HiltViewModel
+class DownloadsViewModel @Inject constructor(
+ private val downloadRepository: DownloadRepository,
+ private val playbackController: PlaybackController,
+) : ViewModel() {
+
+ val uiState: StateFlow = downloadRepository.downloadedTracks()
+ .map { tracks -> DownloadsUiState(tracks.filter { it.status == DownloadStatus.COMPLETE }) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState())
+
+ fun playTrack(index: Int) {
+ val tracks = uiState.value.tracks
+ if (index !in tracks.indices) return
+ viewModelScope.launch {
+ playbackController.ensureConnected()
+ playbackController.playQueue(tracks.map { it.toMediaItem() }, startIndex = index)
+ }
+ }
+
+ fun remove(trackId: String) {
+ viewModelScope.launch { downloadRepository.delete(trackId) }
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt
index ae52047..9df7ef5 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailScreen.kt
@@ -3,6 +3,7 @@ package com.InfernalAquatics.deepwave.ui.library
import android.content.res.Configuration
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
@@ -12,17 +13,20 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -32,12 +36,18 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.media3.common.util.UnstableApi
import com.InfernalAquatics.deepwave.R
+import com.InfernalAquatics.deepwave.data.download.BitrateOption
+import com.InfernalAquatics.deepwave.data.download.TrackDownloadState
import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
import com.InfernalAquatics.deepwave.ui.components.Artwork
+import com.InfernalAquatics.deepwave.ui.components.BitratePickerSheet
+import com.InfernalAquatics.deepwave.ui.components.DownloadStateBadge
import com.InfernalAquatics.deepwave.ui.components.TrackRow
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
@UnstableApi
@Composable
@@ -46,12 +56,33 @@ fun AlbumDetailScreen(
viewModel: AlbumDetailViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
- AlbumDetailContent(uiState = uiState, onBack = onBack, onSongClick = viewModel::playSong)
+ val defaultBitrate by viewModel.defaultBitrate.collectAsState()
+ AlbumDetailContent(
+ uiState = uiState,
+ defaultBitrate = defaultBitrate,
+ onBack = onBack,
+ onSongClick = viewModel::playSong,
+ onDownloadSong = viewModel::requestDownloadSong,
+ onDownloadAlbum = viewModel::requestDownloadAlbum,
+ onConfirmDownload = viewModel::confirmDownload,
+ onDismissDownloadPicker = viewModel::dismissDownloadPicker,
+ observeDownloadState = viewModel::observeDownloadState,
+ )
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun AlbumDetailContent(uiState: AlbumDetailUiState, onBack: () -> Unit, onSongClick: (Int) -> Unit = {}) {
+private fun AlbumDetailContent(
+ uiState: AlbumDetailUiState,
+ defaultBitrate: BitrateOption,
+ onBack: () -> Unit,
+ onSongClick: (Int) -> Unit = {},
+ onDownloadSong: (Int) -> Unit = {},
+ onDownloadAlbum: () -> Unit = {},
+ onConfirmDownload: (BitrateOption) -> Unit = {},
+ onDismissDownloadPicker: () -> Unit = {},
+ observeDownloadState: (String) -> Flow = { flowOf(TrackDownloadState.NotDownloaded) },
+) {
Scaffold(
topBar = {
TopAppBar(
@@ -73,23 +104,45 @@ private fun AlbumDetailContent(uiState: AlbumDetailUiState, onBack: () -> Unit,
Text(text = uiState.errorMessage, style = MaterialTheme.typography.bodyLarge)
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
- item { AlbumHeader(uiState.album) }
+ item { AlbumHeader(uiState.album, onDownloadAlbum = onDownloadAlbum) }
itemsIndexed(uiState.songs, key = { _, song -> song.id }) { index, song ->
TrackRow(
title = song.title,
subtitle = song.artistName ?: uiState.album?.artistName.orEmpty(),
coverArtUrl = coverArtUrl(song.coverArtId ?: uiState.album?.coverArtId),
onClick = { onSongClick(index) },
+ trailingContent = {
+ DownloadBadge(
+ trackId = song.id,
+ observeState = observeDownloadState,
+ onClick = { onDownloadSong(index) },
+ )
+ },
)
}
}
}
+
+ if (uiState.pendingDownloadTarget != null) {
+ BitratePickerSheet(
+ onSelect = onConfirmDownload,
+ onDismiss = onDismissDownloadPicker,
+ defaultOption = defaultBitrate,
+ )
+ }
}
}
}
@Composable
-private fun AlbumHeader(album: Album?) {
+private fun DownloadBadge(trackId: String, observeState: (String) -> Flow, onClick: () -> Unit) {
+ val flow = remember(trackId) { observeState(trackId) }
+ val state by flow.collectAsState(initial = TrackDownloadState.NotDownloaded)
+ DownloadStateBadge(state = state, onClick = onClick)
+}
+
+@Composable
+private fun AlbumHeader(album: Album?, onDownloadAlbum: () -> Unit) {
if (album == null) return
Column(
modifier = Modifier
@@ -106,6 +159,11 @@ private fun AlbumHeader(album: Album?) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
+ Spacer(modifier = Modifier.height(16.dp))
+ OutlinedButton(onClick = onDownloadAlbum) {
+ Icon(Icons.Filled.Download, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
+ Text(stringResource(R.string.download_album))
+ }
}
}
@@ -138,6 +196,24 @@ private fun AlbumDetailContentPreview() {
DeepwaveTheme {
AlbumDetailContent(
uiState = AlbumDetailUiState(isLoading = false, album = sampleAlbum, songs = sampleSongs),
+ defaultBitrate = BitrateOption.Normal,
+ onBack = {},
+ )
+ }
+}
+
+@Preview(name = "Picking quality", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 411, heightDp = 891)
+@Composable
+private fun AlbumDetailContentPickerPreview() {
+ DeepwaveTheme {
+ AlbumDetailContent(
+ uiState = AlbumDetailUiState(
+ isLoading = false,
+ album = sampleAlbum,
+ songs = sampleSongs,
+ pendingDownloadTarget = DownloadTarget.Album,
+ ),
+ defaultBitrate = BitrateOption.Normal,
onBack = {},
)
}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt
index 8a23fcd..6b813b8 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/AlbumDetailViewModel.kt
@@ -5,27 +5,42 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.navigation.toRoute
+import com.InfernalAquatics.deepwave.data.download.BitrateOption
+import com.InfernalAquatics.deepwave.data.download.DownloadRepository
+import com.InfernalAquatics.deepwave.data.download.TrackDownloadState
import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.model.Song
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
+import com.InfernalAquatics.deepwave.data.settings.DownloadPreferences
import com.InfernalAquatics.deepwave.media.PlaybackController
import com.InfernalAquatics.deepwave.media.toMediaItem
import com.InfernalAquatics.deepwave.ui.navigation.Route
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException
import javax.inject.Inject
+/** What a bitrate pick from the sheet applies to - a single track, or every track in the album. */
+sealed interface DownloadTarget {
+ data class Track(val index: Int) : DownloadTarget
+ data object Album : DownloadTarget
+}
+
data class AlbumDetailUiState(
val isLoading: Boolean = true,
val album: Album? = null,
val songs: List = emptyList(),
val errorMessage: String? = null,
+ val pendingDownloadTarget: DownloadTarget? = null,
)
@UnstableApi
@@ -34,12 +49,17 @@ class AlbumDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val libraryRepository: LibraryRepository,
private val playbackController: PlaybackController,
+ private val downloadRepository: DownloadRepository,
+ private val downloadPreferences: DownloadPreferences,
) : ViewModel() {
private val albumId = savedStateHandle.toRoute().albumId
private val _uiState = MutableStateFlow(AlbumDetailUiState())
val uiState: StateFlow = _uiState.asStateFlow()
+ val defaultBitrate: StateFlow = downloadPreferences.defaultBitrate
+ .stateIn(viewModelScope, SharingStarted.Eagerly, BitrateOption.Normal)
+
init {
load()
}
@@ -53,6 +73,37 @@ class AlbumDetailViewModel @Inject constructor(
}
}
+ fun observeDownloadState(trackId: String): Flow = downloadRepository.observeState(trackId)
+
+ fun requestDownloadSong(index: Int) {
+ _uiState.update { it.copy(pendingDownloadTarget = DownloadTarget.Track(index)) }
+ }
+
+ fun requestDownloadAlbum() {
+ _uiState.update { it.copy(pendingDownloadTarget = DownloadTarget.Album) }
+ }
+
+ fun dismissDownloadPicker() {
+ _uiState.update { it.copy(pendingDownloadTarget = null) }
+ }
+
+ fun confirmDownload(bitrateOption: BitrateOption) {
+ val target = _uiState.value.pendingDownloadTarget ?: return
+ val songs = _uiState.value.songs
+ viewModelScope.launch {
+ val wifiOnly = downloadPreferences.wifiOnly.first()
+ when (target) {
+ is DownloadTarget.Track -> songs.getOrNull(target.index)?.let {
+ downloadRepository.enqueue(it, bitrateOption, wifiOnly)
+ }
+ DownloadTarget.Album -> songs.forEach {
+ downloadRepository.enqueue(it, bitrateOption, wifiOnly)
+ }
+ }
+ }
+ _uiState.update { it.copy(pendingDownloadTarget = null) }
+ }
+
private fun load() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt
index e79721f..a1506c1 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt
@@ -19,6 +19,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.InfernalAquatics.deepwave.ui.components.MiniPlayerBar
+import com.InfernalAquatics.deepwave.ui.downloads.DownloadsScreen
import com.InfernalAquatics.deepwave.ui.library.AlbumDetailScreen
import com.InfernalAquatics.deepwave.ui.library.ArtistDetailScreen
import com.InfernalAquatics.deepwave.ui.login.LoginScreen
@@ -102,8 +103,12 @@ fun DeepwaveNavHost(
popUpTo { inclusive = true }
}
},
+ onOpenDownloads = { navController.navigate(Route.Downloads) },
)
}
+ composable {
+ DownloadsScreen(onBack = { navController.popBackStack() })
+ }
composable {
ArtistDetailScreen(
onBack = { navController.popBackStack() },
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt
index 5bbde3a..97c3fbb 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/Route.kt
@@ -9,4 +9,5 @@ sealed interface Route {
@Serializable data class ArtistDetail(val artistId: String) : Route
@Serializable data class AlbumDetail(val albumId: String) : Route
@Serializable data object NowPlaying : Route
+ @Serializable data object Downloads : Route
}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsScreen.kt
index 4fa385a..f67b1e3 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsScreen.kt
@@ -1,8 +1,10 @@
package com.InfernalAquatics.deepwave.ui.settings
import android.content.res.Configuration
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -10,33 +12,62 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.InfernalAquatics.deepwave.R
+import com.InfernalAquatics.deepwave.data.download.BitrateOption
+import com.InfernalAquatics.deepwave.ui.components.BitratePickerSheet
import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onLoggedOut: () -> Unit,
+ onOpenDownloads: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
) {
+ val uiState by viewModel.uiState.collectAsState()
+ val wifiOnly by viewModel.wifiOnly.collectAsState()
+ val defaultBitrate by viewModel.defaultBitrate.collectAsState()
SettingsContent(
+ uiState = uiState,
+ wifiOnly = wifiOnly,
+ defaultBitrate = defaultBitrate,
onBack = onBack,
onLogOutClick = { viewModel.logout(onLoggedOut) },
+ onOpenDownloads = onOpenDownloads,
+ onWifiOnlyChange = viewModel::setWifiOnly,
+ onOpenDefaultQualityPicker = viewModel::openDefaultQualityPicker,
+ onDismissDefaultQualityPicker = viewModel::dismissDefaultQualityPicker,
+ onSelectDefaultQuality = viewModel::setDefaultBitrate,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun SettingsContent(onBack: () -> Unit, onLogOutClick: () -> Unit) {
+private fun SettingsContent(
+ uiState: SettingsUiState,
+ wifiOnly: Boolean,
+ defaultBitrate: BitrateOption,
+ onBack: () -> Unit,
+ onLogOutClick: () -> Unit,
+ onOpenDownloads: () -> Unit,
+ onWifiOnlyChange: (Boolean) -> Unit,
+ onOpenDefaultQualityPicker: () -> Unit,
+ onDismissDefaultQualityPicker: () -> Unit,
+ onSelectDefaultQuality: (BitrateOption) -> Unit,
+) {
Scaffold(
topBar = {
TopAppBar(
@@ -49,16 +80,37 @@ private fun SettingsContent(onBack: () -> Unit, onLogOutClick: () -> Unit) {
)
},
) { innerPadding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding)
- .padding(16.dp),
- ) {
- Button(onClick = onLogOutClick) {
+ Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.settings_downloads)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onOpenDownloads),
+ )
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.settings_wifi_only_downloads)) },
+ trailingContent = { Switch(checked = wifiOnly, onCheckedChange = onWifiOnlyChange) },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.settings_default_quality)) },
+ supportingContent = { Text(defaultBitrate.label) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onOpenDefaultQualityPicker),
+ )
+ Button(onClick = onLogOutClick, modifier = Modifier.padding(16.dp)) {
Text(stringResource(R.string.settings_log_out))
}
}
+
+ if (uiState.isPickingDefaultQuality) {
+ BitratePickerSheet(
+ onSelect = onSelectDefaultQuality,
+ onDismiss = onDismissDefaultQualityPicker,
+ defaultOption = defaultBitrate,
+ )
+ }
}
}
@@ -66,6 +118,17 @@ private fun SettingsContent(onBack: () -> Unit, onLogOutClick: () -> Unit) {
@Composable
private fun SettingsContentPreview() {
DeepwaveTheme {
- SettingsContent(onBack = {}, onLogOutClick = {})
+ SettingsContent(
+ uiState = SettingsUiState(),
+ wifiOnly = true,
+ defaultBitrate = BitrateOption.Normal,
+ onBack = {},
+ onLogOutClick = {},
+ onOpenDownloads = {},
+ onWifiOnlyChange = {},
+ onOpenDefaultQualityPicker = {},
+ onDismissDefaultQualityPicker = {},
+ onSelectDefaultQuality = {},
+ )
}
}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsViewModel.kt
index 316902e..ede3026 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsViewModel.kt
@@ -2,19 +2,57 @@ package com.InfernalAquatics.deepwave.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.InfernalAquatics.deepwave.data.download.BitrateOption
import com.InfernalAquatics.deepwave.data.repository.ServerRepository
+import com.InfernalAquatics.deepwave.data.settings.DownloadPreferences
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
+data class SettingsUiState(val isPickingDefaultQuality: Boolean = false)
+
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val serverRepository: ServerRepository,
+ private val downloadPreferences: DownloadPreferences,
) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(SettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ val wifiOnly: StateFlow = downloadPreferences.wifiOnly
+ .stateIn(viewModelScope, SharingStarted.Eagerly, true)
+
+ val defaultBitrate: StateFlow = downloadPreferences.defaultBitrate
+ .stateIn(viewModelScope, SharingStarted.Eagerly, BitrateOption.Normal)
+
fun logout(onLoggedOut: () -> Unit) {
viewModelScope.launch {
serverRepository.logout()
onLoggedOut()
}
}
+
+ fun setWifiOnly(value: Boolean) {
+ viewModelScope.launch { downloadPreferences.setWifiOnly(value) }
+ }
+
+ fun openDefaultQualityPicker() {
+ _uiState.update { it.copy(isPickingDefaultQuality = true) }
+ }
+
+ fun dismissDefaultQualityPicker() {
+ _uiState.update { it.copy(isPickingDefaultQuality = false) }
+ }
+
+ fun setDefaultBitrate(option: BitrateOption) {
+ viewModelScope.launch { downloadPreferences.setDefaultBitrate(option) }
+ _uiState.update { it.copy(isPickingDefaultQuality = false) }
+ }
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9fbb6a9..9f82369 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -17,6 +17,22 @@
Settings
Log out
+ Downloads
+ Download over Wi-Fi only
+ Default download quality
+
+ Downloads
+ Downloading
+ Download quality
+ Download album
+ Download
+ Downloading
+ Downloaded
+ Download failed, tap to retry
+ Remove download
+
+ Downloads
+ No downloads yet
Nothing playing
Play
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
index c2913d3..5ec079a 100644
--- a/app/src/main/res/xml/backup_rules.xml
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -8,4 +8,7 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
index df2fd3f..526dd8e 100644
--- a/app/src/main/res/xml/data_extraction_rules.xml
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -7,9 +7,11 @@
+
+
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d9776c0..28c26ae 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -22,6 +22,8 @@ coil = "3.3.0"
media3 = "1.11.1"
mockitoCore = "5.23.0"
kotlinxCoroutinesTest = "1.11.0"
+room = "2.8.5"
+workManager = "2.11.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -44,6 +46,8 @@ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "l
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hiltAndroid" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hiltAndroid" }
androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" }
+androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidxHilt" }
+androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationConverter" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
@@ -59,6 +63,12 @@ androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver3", version.ref = "okhttp" }
mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockitoCore" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
+androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
+androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
+androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "workManager" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }