let the user allow downloads on mobile data

Downloads asked the platform for an unmetered network and nothing more, so
there was no way to run them on mobile. A setting under Downloads now decides,
defaulting to Wi-Fi only, and it also governs roaming: the platform permits
roaming by default, which would have contradicted the Wi-Fi-only choice.

The flag rides the enqueue request like the download quality does, cached in the
source repository so the poller does not read the preference per row.
This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent 02b3ddbb98
commit caf8369924
9 changed files with 81 additions and 4 deletions
@@ -4,6 +4,7 @@ data class EngineRequest(
val uri: String,
val path: String,
val title: String,
val allowMetered: Boolean = false,
)
enum class EngineStatus { Pending, Running, Completed, Failed }
@@ -15,8 +15,10 @@ class SystemDownloadEngine(
.setTitle(request.title)
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
// Downloads wait for an unmetered network until the preference for them exists.
.setAllowedOverMetered(false)
// Wi-Fi only unless the preference allows a metered network, and roaming counts as
// one of those; the platform otherwise permits it by default.
.setAllowedOverMetered(request.allowMetered)
.setAllowedOverRoaming(request.allowMetered)
return manager().enqueue(download)
}
@@ -3,6 +3,7 @@ package com.subtracks.data.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
@@ -88,6 +89,14 @@ class UserPreferences(
private val downloadQualityKey = stringPreferencesKey("download_quality")
fun downloadOverMetered(): Flow<Boolean> = store.data.map { prefs -> prefs[downloadOverMeteredKey] ?: false }
suspend fun setDownloadOverMetered(allowed: Boolean) {
store.edit { prefs -> prefs[downloadOverMeteredKey] = allowed }
}
private val downloadOverMeteredKey = booleanPreferencesKey("download_over_metered")
fun syncConcurrency(): Flow<Int> = store.data.map { prefs -> (prefs[syncConcurrencyKey] ?: DEFAULT_FETCH_CONCURRENCY).coerceAtLeast(1) }
suspend fun setSyncConcurrency(value: Int) {
@@ -163,7 +163,12 @@ class DownloadRepository(
val engineId =
try {
engine.enqueue(
EngineRequest(uri = url, path = enginePath(row.sourceId, row.songId), title = title),
EngineRequest(
uri = url,
path = enginePath(row.sourceId, row.songId),
title = title,
allowMetered = sourceRepository.downloadsAllowedOverMetered(),
),
)
} catch (e: CancellationException) {
throw e
@@ -52,10 +52,18 @@ class SourceRepository(
@Volatile
private var downloadQuality = StreamQuality()
@Volatile
private var allowMeteredDownloads = false
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
init {
scope.launch {
prefs.downloadQuality().collect { downloadQuality = it }
}
scope.launch {
prefs.downloadOverMetered().collect { allowMeteredDownloads = it }
}
scope.launch {
combine(
db.sourcesDao().activeSubsonicConfig(),
@@ -31,6 +31,7 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -70,6 +71,7 @@ fun SettingsRoute(
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
SettingsScreen(
sources = sources,
activeSourceId = activeSourceId,
@@ -77,12 +79,14 @@ fun SettingsRoute(
mobileQuality = mobileQuality,
syncConcurrency = syncConcurrency,
downloadQuality = downloadQuality,
downloadOverMetered = downloadOverMetered,
onSelectSource = viewModel::selectSource,
onEditServer = onEditServer,
onWifiQualityChange = viewModel::setWifiQuality,
onMobileQualityChange = viewModel::setMobileQuality,
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
onDownloadQualityChange = viewModel::setDownloadQuality,
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
onAddServer = onAddServer,
onOpenDownloads = onOpenDownloads,
onBack = onBack,
@@ -98,12 +102,14 @@ fun SettingsScreen(
mobileQuality: StreamQuality,
syncConcurrency: Int,
downloadQuality: StreamQuality,
downloadOverMetered: Boolean,
onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit,
onMobileQualityChange: (StreamQuality) -> Unit,
onSyncConcurrencyChange: (Int) -> Unit,
onDownloadQualityChange: (StreamQuality) -> Unit,
onDownloadOverMeteredChange: (Boolean) -> Unit,
onAddServer: () -> Unit,
onOpenDownloads: () -> Unit,
onBack: () -> Unit,
@@ -199,6 +205,17 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text("Download over mobile data") },
supportingContent = { Text(if (downloadOverMetered) "Wi-Fi and mobile" else "Wi-Fi only") },
trailingContent = {
Switch(checked = downloadOverMetered, onCheckedChange = onDownloadOverMeteredChange)
},
modifier = Modifier.clickable { onDownloadOverMeteredChange(!downloadOverMetered) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text("Manage downloads") },
@@ -38,6 +38,11 @@ class SettingsViewModel(
.downloadQuality()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality())
val downloadOverMetered: StateFlow<Boolean> =
userPreferences
.downloadOverMetered()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val syncConcurrency: StateFlow<Int> =
userPreferences
.syncConcurrency()
@@ -59,6 +64,10 @@ class SettingsViewModel(
viewModelScope.launch { userPreferences.setDownloadQuality(quality) }
}
fun setDownloadOverMetered(allowed: Boolean) {
viewModelScope.launch { userPreferences.setDownloadOverMetered(allowed) }
}
fun setSyncConcurrency(value: Int) {
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
}
@@ -52,6 +52,7 @@ class DownloadRepositoryTest {
private lateinit var repository: DownloadRepository
private lateinit var artwork: ArtworkStore
private lateinit var dir: File
private val prefs = fakeUserPreferences()
private val messages = CopyOnWriteArrayList<String>()
private val requestedArt = CopyOnWriteArrayList<String>()
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
@@ -73,7 +74,7 @@ class DownloadRepositoryTest {
.setDriver(BundledSQLiteDriver())
.build()
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
sources = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork)
sources = SourceRepository(db, OkHttpClient(), prefs, artwork)
engine = FakeDownloadEngine()
dir = File(context.cacheDir, "downloads-${System.nanoTime()}")
repository =
@@ -956,6 +957,29 @@ class DownloadRepositoryTest {
assertNull(repository.localUri("s1"))
}
@Test
fun downloadsAreWifiOnlyUntilThePreferenceAllowsMetered() =
runTest {
seedLibrary()
runBlocking { repository.download(1, "s1") }
assertFalse(
engine.requests
.single()
.second.allowMetered,
)
prefs.setDownloadOverMetered(true)
await { sources.downloadsAllowedOverMetered() }
runBlocking { repository.download(1, "s2") }
assertTrue(
engine.requests
.last()
.second.allowMetered,
)
}
private fun requestedSongIds(): List<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
private fun seedSongs(tracks: IntRange) {
@@ -344,12 +344,14 @@ class AppScreenshotTest {
mobileQuality = StreamQuality(192, "mp3"),
syncConcurrency = 4,
downloadQuality = StreamQuality(128, "opus"),
downloadOverMetered = false,
onSelectSource = {},
onEditServer = {},
onWifiQualityChange = {},
onMobileQualityChange = {},
onSyncConcurrencyChange = {},
onDownloadQualityChange = {},
onDownloadOverMeteredChange = {},
onAddServer = {},
onOpenDownloads = {},
onBack = {},